Compare commits

...

540 Commits

Author SHA1 Message Date
Jannat Patel
57c1a6b540 Merge pull request #1676 from pateljannat/issues-127
fix: misc issues
2025-08-08 12:08:04 +05:30
Jannat Patel
8dba0e8242 Merge pull request #1677 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-08-08 12:06:59 +05:30
Jannat Patel
ee715f6387 chore: fixed linters 2025-08-08 10:47:21 +05:30
Jannat Patel
b770b30334 chore: Italian translations 2025-08-08 06:54:22 +05:30
Jannat Patel
d61abac126 fix: validate is uploaded svg is malicious 2025-08-07 17:33:32 +05:30
Jannat Patel
ccf28b8012 refactor: bring course title down from the gradient in course cards 2025-08-07 17:23:14 +05:30
Jannat Patel
3762cb06bb Merge pull request #1671 from pateljannat/notes
feat: notes and highlights in lesson
2025-08-07 17:11:46 +05:30
Jannat Patel
15400f2a3e test: open community tab before testing discussions 2025-08-07 17:01:55 +05:30
Jannat Patel
20d1b1fe83 Merge pull request #1675 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-08-07 16:05:03 +05:30
Jannat Patel
73844f8813 fix: minor changes with visibility and change 2025-08-07 16:04:36 +05:30
Jannat Patel
2187553625 chore: Serbian (Latin) translations 2025-08-07 06:36:14 +05:30
Jannat Patel
984b2a5dea chore: Serbian (Cyrillic) translations 2025-08-07 06:36:13 +05:30
Jannat Patel
9098d9454f Merge pull request #1674 from pateljannat/issues-126
fix: video statistics scroll and time format
2025-08-06 11:28:15 +05:30
Jannat Patel
027dd93fb5 fix: reload notes when moving to another lesson 2025-08-06 11:13:16 +05:30
Jannat Patel
a005adc89a Merge pull request #1672 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-08-06 10:32:09 +05:30
Jannat Patel
866ef04fbf chore: Esperanto translations 2025-08-06 05:34:46 +05:30
Jannat Patel
00b6f97e3a chore: Serbian (Latin) translations 2025-08-06 05:34:45 +05:30
Jannat Patel
a1d21b1a2a chore: Bosnian translations 2025-08-06 05:34:43 +05:30
Jannat Patel
7358ea43d8 chore: Croatian translations 2025-08-06 05:34:42 +05:30
Jannat Patel
88c69311eb chore: Thai translations 2025-08-06 05:34:40 +05:30
Jannat Patel
c1e45e5d0d chore: Persian translations 2025-08-06 05:34:39 +05:30
Jannat Patel
fe78de2417 chore: Indonesian translations 2025-08-06 05:34:37 +05:30
Jannat Patel
4c1fc201e6 chore: Portuguese, Brazilian translations 2025-08-06 05:34:36 +05:30
Jannat Patel
3f5d270915 chore: Vietnamese translations 2025-08-06 05:34:34 +05:30
Jannat Patel
a452fbeb07 chore: Chinese Simplified translations 2025-08-06 05:34:33 +05:30
Jannat Patel
a6f02c245f chore: Turkish translations 2025-08-06 05:34:31 +05:30
Jannat Patel
cb4f9129d6 chore: Swedish translations 2025-08-06 05:34:30 +05:30
Jannat Patel
9c5d64c211 chore: Serbian (Cyrillic) translations 2025-08-06 05:34:28 +05:30
Jannat Patel
41dc0ecc60 chore: Russian translations 2025-08-06 05:34:26 +05:30
Jannat Patel
6b9409b889 chore: Portuguese translations 2025-08-06 05:34:25 +05:30
Jannat Patel
ea66eeed6c chore: Polish translations 2025-08-06 05:34:24 +05:30
Jannat Patel
a419d28ef1 chore: Dutch translations 2025-08-06 05:34:22 +05:30
Jannat Patel
481dfc24fd chore: Italian translations 2025-08-06 05:34:20 +05:30
Jannat Patel
ed686a7d52 chore: Hungarian translations 2025-08-06 05:34:19 +05:30
Jannat Patel
b4c5a07800 chore: German translations 2025-08-06 05:34:17 +05:30
Jannat Patel
6ae16f7fef chore: Czech translations 2025-08-06 05:34:16 +05:30
Jannat Patel
4aae2ed3b8 chore: Arabic translations 2025-08-06 05:34:14 +05:30
Jannat Patel
81d4137b20 chore: Spanish translations 2025-08-06 05:34:13 +05:30
Jannat Patel
77ecb02a17 feat: notes in lesson 2025-08-05 20:00:09 +05:30
Jannat Patel
4a375f92ed Merge pull request #1668 from frappe/pot_develop_2025-08-01
chore: update POT file
2025-08-04 20:05:22 +05:30
Jannat Patel
7caf91460a Merge pull request #1669 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-08-04 20:05:12 +05:30
Jannat Patel
0e015c8b97 chore: Indonesian translations 2025-08-03 04:35:30 +05:30
frappe-pr-bot
7b69ddb14d chore: update POT file 2025-08-01 16:04:44 +00:00
Jannat Patel
2271eb270e Merge pull request #1667 from harshpwctech/develop
refactor: Announcement mail being sent to students in BCC
2025-08-01 17:32:41 +05:30
CA Harsh Agrawal
7e5b2e4e79 refactor: Announcement mail being sent to students in BCC 2025-08-01 17:02:48 +05:30
Jannat Patel
124b9d9ea5 fix: video statistics scroll 2025-07-30 17:31:33 +05:30
Jannat Patel
36076068ec fix: text padding on card gradient 2025-07-30 12:30:12 +05:30
Frappe PR Bot
c868354b5b chore(release): Bumped to Version 2.33.0 2025-07-30 06:14:36 +00:00
Jannat Patel
db91f0b2a0 Merge pull request #1663 from pateljannat/issues-125
fix: show video statistics watch time in minutes
2025-07-30 11:43:01 +05:30
Jannat Patel
d7e83bb78e fix: show video statistics watch time in minutes 2025-07-30 11:30:17 +05:30
Jannat Patel
feb2a39e05 Merge pull request #1661 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-30 10:53:15 +05:30
Jannat Patel
a6cf910d05 chore: Esperanto translations 2025-07-30 04:23:56 +05:30
Jannat Patel
b891b44ac6 chore: Spanish translations 2025-07-30 04:23:54 +05:30
Jannat Patel
026a3ebb81 chore: Serbian (Latin) translations 2025-07-30 04:23:53 +05:30
Jannat Patel
71ba246011 chore: Bosnian translations 2025-07-30 04:23:51 +05:30
Jannat Patel
a391204fa6 chore: Croatian translations 2025-07-30 04:23:49 +05:30
Jannat Patel
9c773399a8 chore: Thai translations 2025-07-30 04:23:48 +05:30
Jannat Patel
528b85352a chore: Persian translations 2025-07-30 04:23:47 +05:30
Jannat Patel
249c369c14 chore: Indonesian translations 2025-07-30 04:23:45 +05:30
Jannat Patel
9803fc1031 chore: Portuguese, Brazilian translations 2025-07-30 04:23:44 +05:30
Jannat Patel
299fde1c98 chore: Vietnamese translations 2025-07-30 04:23:43 +05:30
Jannat Patel
7f55734fbb chore: Chinese Simplified translations 2025-07-30 04:23:41 +05:30
Jannat Patel
efe230865a chore: Turkish translations 2025-07-30 04:23:40 +05:30
Jannat Patel
6e52e684c8 chore: Swedish translations 2025-07-30 04:23:38 +05:30
Jannat Patel
99d880297a chore: Serbian (Cyrillic) translations 2025-07-30 04:23:36 +05:30
Jannat Patel
dec706ae72 chore: Russian translations 2025-07-30 04:23:35 +05:30
Jannat Patel
2e60f0a0c2 chore: Portuguese translations 2025-07-30 04:23:34 +05:30
Jannat Patel
ef612f86e5 chore: Polish translations 2025-07-30 04:23:32 +05:30
Jannat Patel
9c16e03ea7 chore: Dutch translations 2025-07-30 04:23:31 +05:30
Jannat Patel
7780c0310e chore: Italian translations 2025-07-30 04:23:29 +05:30
Jannat Patel
b0a23c0d1a chore: Hungarian translations 2025-07-30 04:23:27 +05:30
Jannat Patel
05c85cea08 chore: German translations 2025-07-30 04:23:26 +05:30
Jannat Patel
1ffae0a1de chore: Czech translations 2025-07-30 04:23:24 +05:30
Jannat Patel
15cbccd15f chore: Arabic translations 2025-07-30 04:23:23 +05:30
Jannat Patel
266b2f2ac8 chore: French translations 2025-07-30 04:23:21 +05:30
Jannat Patel
26f9fb4199 Merge pull request #1658 from frappe/pot_develop_2025-07-25
chore: update POT file
2025-07-29 12:05:37 +05:30
frappe-pr-bot
67887fb6ef chore: update POT file 2025-07-25 16:04:39 +00:00
Jannat Patel
3d102e39ff Merge pull request #1657 from pateljannat/course-card-gradient
feat: course card gradient
2025-07-25 18:56:50 +05:30
Jannat Patel
ddd9089130 fix: color swatch input style 2025-07-25 18:31:46 +05:30
Jannat Patel
d8ce88ab57 fix: color swatch input style 2025-07-25 18:30:58 +05:30
Jannat Patel
01794a47c6 feat: set a random color is no color or image is present 2025-07-25 17:46:50 +05:30
Jannat Patel
17626dbbdb feat: course card gradient 2025-07-25 17:29:48 +05:30
Jannat Patel
e5bd86658d Merge pull request #1655 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-24 10:41:13 +05:30
Jannat Patel
e911dc1353 chore: Thai translations 2025-07-24 02:45:56 +05:30
Jannat Patel
27e3e5aa6a chore: Indonesian translations 2025-07-24 02:45:53 +05:30
Jannat Patel
5b65525bf1 chore: Portuguese translations 2025-07-24 02:45:46 +05:30
Jannat Patel
277804f8b1 chore: Hungarian translations 2025-07-24 02:45:42 +05:30
Jannat Patel
4c77802e3c Merge pull request #1653 from pateljannat/issues-124
fix: progress timer in lessons
2025-07-23 11:32:51 +05:30
Jannat Patel
aacfea6ea5 fix: progress timer in lessons 2025-07-23 11:31:41 +05:30
Frappe PR Bot
6d55040e43 chore(release): Bumped to Version 2.32.2 2025-07-23 05:31:05 +00:00
Jannat Patel
290f785a47 Merge pull request #1651 from pateljannat/issues-123
fix: vimeo video embed with plyr
2025-07-23 11:00:03 +05:30
Jannat Patel
39ef187f6b fix: vimeo video embed with plyr 2025-07-23 10:44:53 +05:30
Frappe PR Bot
a7a475e763 chore(release): Bumped to Version 2.32.1 2025-07-22 13:31:38 +00:00
Jannat Patel
6eb380ea38 Merge pull request #1648 from pateljannat/issues-122
fix: play embed videos on Lesson Form
2025-07-22 14:20:42 +05:30
Jannat Patel
4d150cb323 fix: play embed videos on Lesson Form 2025-07-22 14:11:29 +05:30
Jannat Patel
09d6d99b14 Merge pull request #1647 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-22 10:24:07 +05:30
Jannat Patel
5e7fd8baff chore: Esperanto translations 2025-07-22 02:06:41 +05:30
Jannat Patel
52c159e2e8 chore: French translations 2025-07-22 02:06:40 +05:30
Jannat Patel
67e8feb879 chore: Serbian (Latin) translations 2025-07-22 02:06:38 +05:30
Jannat Patel
a5b61d5244 chore: Bosnian translations 2025-07-22 02:06:37 +05:30
Jannat Patel
decc3a16ed chore: Croatian translations 2025-07-22 02:06:35 +05:30
Jannat Patel
7f39e9f0cc chore: Thai translations 2025-07-22 02:06:34 +05:30
Jannat Patel
95afa1a6ad chore: Persian translations 2025-07-22 02:06:32 +05:30
Jannat Patel
0d0bb5f9e2 chore: Portuguese, Brazilian translations 2025-07-22 02:06:31 +05:30
Jannat Patel
3dd5ce5035 chore: Vietnamese translations 2025-07-22 02:06:29 +05:30
Jannat Patel
549e56d551 chore: Chinese Simplified translations 2025-07-22 02:06:28 +05:30
Jannat Patel
50b6215d1e chore: Turkish translations 2025-07-22 02:06:27 +05:30
Jannat Patel
ff69bfdce7 chore: Swedish translations 2025-07-22 02:06:25 +05:30
Jannat Patel
c04cc8ec0f chore: Serbian (Cyrillic) translations 2025-07-22 02:06:24 +05:30
Jannat Patel
f324de2254 chore: Russian translations 2025-07-22 02:06:22 +05:30
Jannat Patel
40af4e6f34 chore: Portuguese translations 2025-07-22 02:06:21 +05:30
Jannat Patel
5d9b66b5cb chore: Polish translations 2025-07-22 02:06:20 +05:30
Jannat Patel
d2a8277c13 chore: Dutch translations 2025-07-22 02:06:18 +05:30
Jannat Patel
ada85fc0f3 chore: Italian translations 2025-07-22 02:06:17 +05:30
Jannat Patel
505345eff7 chore: Hungarian translations 2025-07-22 02:06:15 +05:30
Jannat Patel
2911ade880 chore: German translations 2025-07-22 02:06:14 +05:30
Jannat Patel
8980dc8f9c chore: Czech translations 2025-07-22 02:06:12 +05:30
Jannat Patel
d94a1c47c0 chore: Arabic translations 2025-07-22 02:06:11 +05:30
Jannat Patel
99c3e5182d chore: Spanish translations 2025-07-22 02:06:09 +05:30
Jannat Patel
70e39fee40 Merge pull request #1646 from frappe/pot_develop_2025-07-18
chore: update POT file
2025-07-21 19:30:03 +05:30
frappe-pr-bot
26d6bec8a0 chore: update POT file 2025-07-18 16:05:11 +00:00
Jannat Patel
c9ac1a1402 Merge pull request #1645 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-18 10:38:18 +05:30
Jannat Patel
6949c1092c chore: Persian translations 2025-07-18 01:39:51 +05:30
Jannat Patel
aae8a54481 Merge pull request #1644 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-17 10:26:39 +05:30
Jannat Patel
e1d93bf670 chore: Serbian (Latin) translations 2025-07-17 01:24:47 +05:30
Jannat Patel
fea0533cb1 chore: Chinese Simplified translations 2025-07-17 01:24:40 +05:30
Jannat Patel
5cd991f02a chore: Swedish translations 2025-07-17 01:24:37 +05:30
Jannat Patel
50a8a605d5 chore: Serbian (Cyrillic) translations 2025-07-17 01:24:36 +05:30
Jannat Patel
9ce7d8f5d6 Merge pull request #1641 from pateljannat/issues-121
chore: upgraded frappe-ui
2025-07-15 15:39:48 +05:30
Jannat Patel
eae2587e4c chore: upgraded frappe-ui 2025-07-15 15:08:32 +05:30
Jannat Patel
323097f201 Merge pull request #1639 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-15 09:44:54 +05:30
Jannat Patel
014499888a chore: Esperanto translations 2025-07-15 01:33:53 +05:30
Jannat Patel
5662de21ae chore: Serbian (Latin) translations 2025-07-15 01:33:52 +05:30
Jannat Patel
17c2eba455 chore: Bosnian translations 2025-07-15 01:33:50 +05:30
Jannat Patel
1f2c986e8f chore: Croatian translations 2025-07-15 01:33:49 +05:30
Jannat Patel
12040b5f6d chore: Thai translations 2025-07-15 01:33:47 +05:30
Jannat Patel
20a985848f chore: Portuguese, Brazilian translations 2025-07-15 01:33:46 +05:30
Jannat Patel
c06c6169e5 chore: Vietnamese translations 2025-07-15 01:33:44 +05:30
Jannat Patel
917aeb79ef chore: Turkish translations 2025-07-15 01:33:43 +05:30
Jannat Patel
c4f36a39fe chore: Swedish translations 2025-07-15 01:33:42 +05:30
Jannat Patel
befedc30ad chore: Serbian (Cyrillic) translations 2025-07-15 01:33:40 +05:30
Jannat Patel
d3bc67daa2 chore: Russian translations 2025-07-15 01:33:39 +05:30
Jannat Patel
5d7e211367 chore: Polish translations 2025-07-15 01:33:37 +05:30
Jannat Patel
fa9daa01ec chore: Dutch translations 2025-07-15 01:33:36 +05:30
Jannat Patel
0ed9dc63b8 chore: Italian translations 2025-07-15 01:33:34 +05:30
Jannat Patel
5dd6b33eb2 chore: Hungarian translations 2025-07-15 01:33:33 +05:30
Jannat Patel
1210b823c7 chore: German translations 2025-07-15 01:33:32 +05:30
Jannat Patel
04240b4b3d chore: Czech translations 2025-07-15 01:33:30 +05:30
Jannat Patel
787f592a1a chore: Arabic translations 2025-07-15 01:33:29 +05:30
Jannat Patel
e7363fbd40 chore: Spanish translations 2025-07-15 01:33:27 +05:30
Jannat Patel
e2762825e5 chore: French translations 2025-07-15 01:33:25 +05:30
Jannat Patel
bbbca70c71 chore: Chinese Simplified translations 2025-07-15 01:33:24 +05:30
Jannat Patel
8dde423866 chore: Persian translations 2025-07-15 01:33:23 +05:30
Jannat Patel
fc4c1c2b7e chore: Portuguese translations 2025-07-15 01:33:21 +05:30
Jannat Patel
bf02e2de3f Merge pull request #1637 from pateljannat/issues-120
fix: increase pageLength for evaluation schedule
2025-07-14 16:57:09 +05:30
Jannat Patel
a26ba4dc6e fix: increase pageLength for evaluation schedule 2025-07-14 16:33:11 +05:30
Frappe PR Bot
f187cc9314 chore(release): Bumped to Version 2.32.0 2025-07-14 06:37:43 +00:00
Jannat Patel
c15c6374f9 Merge pull request #1635 from pateljannat/issues-119
fix: quiz progress issue
2025-07-14 11:51:51 +05:30
Jannat Patel
acec382dfe fix: quiz progress issue 2025-07-14 11:40:55 +05:30
Jannat Patel
fbc078c6b6 Merge pull request #1632 from frappe/pot_develop_2025-07-11
chore: update POT file
2025-07-14 09:37:09 +05:30
frappe-pr-bot
170b20185a chore: update POT file 2025-07-11 16:04:39 +00:00
Jannat Patel
3e8489c13b Merge pull request #1631 from pateljannat/issues-118
fix: misc issues
2025-07-11 11:14:36 +05:30
Jannat Patel
18dfc4c23e test: changed CTA labels 2025-07-11 11:06:29 +05:30
Jannat Patel
e6bae3dc77 fix: changed CTA labels on lists to Create 2025-07-11 10:44:04 +05:30
Jannat Patel
6f9f27c030 fix: delete batch and pass fields prop to brand settings 2025-07-10 22:21:26 +05:30
Jannat Patel
874bef74c7 Merge pull request #1623 from JoeBrar/feature/reorder-chapters
feat: added chapter re-ordering functionality for courses
2025-07-10 12:20:04 +05:30
Jannat Patel
ad483e0916 Merge pull request #1630 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-10 12:19:49 +05:30
Jannat Patel
5b4bbaec20 chore: Portuguese translations 2025-07-10 01:07:19 +05:30
Jannat Patel
b8ae0db0bd Merge pull request #1627 from pateljannat/badge-management
feat: badge management from settings
2025-07-09 10:46:58 +05:30
Jannat Patel
f2c18fad52 fix: misc improvements to badge flow 2025-07-08 19:41:08 +05:30
Jannat Patel
9716655b94 Merge pull request #1626 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-08 14:16:27 +05:30
Jannat Patel
efb317191c feat: badge assignment from settings 2025-07-08 14:12:46 +05:30
Jannat Patel
a47b5db40c chore: Serbian (Cyrillic) translations 2025-07-08 01:10:50 +05:30
Jannat Patel
ec94796b9c chore: Italian translations 2025-07-08 01:10:48 +05:30
Jannat Patel
e3e0cd61a2 chore: Vietnamese translations 2025-07-08 01:10:47 +05:30
Jannat Patel
a438473279 chore: Dutch translations 2025-07-08 01:10:46 +05:30
Jannat Patel
12b5b8b509 chore: Czech translations 2025-07-08 01:10:44 +05:30
Jannat Patel
22442b47a8 chore: Esperanto translations 2025-07-08 01:10:43 +05:30
Jannat Patel
30c8b7d64f chore: Chinese Simplified translations 2025-07-08 01:10:41 +05:30
Jannat Patel
b643575c4f chore: Serbian (Latin) translations 2025-07-08 01:10:40 +05:30
Jannat Patel
7dd7124fac chore: Bosnian translations 2025-07-08 01:10:38 +05:30
Jannat Patel
4b1eebf5bb chore: Croatian translations 2025-07-08 01:10:37 +05:30
Jannat Patel
3257943926 chore: Thai translations 2025-07-08 01:10:36 +05:30
Jannat Patel
24246c83e0 chore: Persian translations 2025-07-08 01:10:34 +05:30
Jannat Patel
a26787f478 chore: Portuguese, Brazilian translations 2025-07-08 01:10:33 +05:30
Jannat Patel
ec3b88f890 chore: Turkish translations 2025-07-08 01:10:31 +05:30
Jannat Patel
7f5f1dad92 chore: Swedish translations 2025-07-08 01:10:30 +05:30
Jannat Patel
b6db128214 chore: Russian translations 2025-07-08 01:10:29 +05:30
Jannat Patel
8831635db2 chore: Portuguese translations 2025-07-08 01:10:28 +05:30
Jannat Patel
e19198b720 chore: Polish translations 2025-07-08 01:10:26 +05:30
Jannat Patel
f618d9dc1a chore: Hungarian translations 2025-07-08 01:10:25 +05:30
Jannat Patel
66a667a0a3 chore: German translations 2025-07-08 01:10:23 +05:30
Jannat Patel
8a4c67f712 chore: Arabic translations 2025-07-08 01:10:22 +05:30
Jannat Patel
fa6ef2e989 chore: Spanish translations 2025-07-08 01:10:21 +05:30
Jannat Patel
7450b99197 chore: French translations 2025-07-08 01:10:19 +05:30
Jannat Patel
023fd272b1 feat: badge list and form 2025-07-07 16:44:48 +05:30
Jannat Patel
84067cb027 Merge pull request #1621 from JoeBrar/fix/image-upload
fix: Changed image extension validation to MIME type validation
2025-07-07 13:01:18 +05:30
Jannat Patel
3087ef70e7 Merge pull request #1619 from JoeBrar/fix/lms-issues
fix: ensure tags wrap correctly to prevent overflow and breaking of layout
2025-07-07 12:59:26 +05:30
Jannat Patel
387385bb1c Merge pull request #1624 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-07 11:13:27 +05:30
Jannat Patel
6766d0d08c Merge pull request #1622 from frappe/pot_develop_2025-07-04
chore: update POT file
2025-07-07 11:13:03 +05:30
Jannat Patel
371d890793 chore: Persian translations 2025-07-07 01:14:35 +05:30
Joedeep Singh
57046c1b38 feat: added chapter re-ordering functionality for courses 2025-07-06 17:02:27 +00:00
frappe-pr-bot
2a64144e94 chore: update POT file 2025-07-04 16:04:41 +00:00
Joedeep Singh
9b0320ccf1 chore: fixed the linter issues 2025-07-04 14:02:13 +00:00
Joedeep Singh
23f209131e Merge branch 'develop' into fix/image-upload 2025-07-04 18:57:03 +05:30
Joedeep Singh
d71f1c7f9a refactor: updated the validateFile function in utils and reused it 2025-07-04 13:22:25 +00:00
Jannat Patel
d21ea2c854 Merge pull request #1618 from pateljannat/member-list-refactor
fix: improved members and evaluators list and form
2025-07-04 12:33:44 +05:30
Jannat Patel
cd7f3ba820 Merge pull request #1620 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-04 12:28:37 +05:30
Jannat Patel
e057d3ed9a fix: improved evaluators list 2025-07-04 12:26:57 +05:30
Joedeep Singh
5f04607a44 fix: Changed image extension validation to MIME type validation 2025-07-03 20:41:23 +00:00
Jannat Patel
9440d13a08 chore: Serbian (Cyrillic) translations 2025-07-04 01:23:16 +05:30
Jannat Patel
85c4f1654e chore: Serbian (Latin) translations 2025-07-04 01:23:15 +05:30
Joedeep Singh
eed339cc64 fix: ensure tags wrap correctly to prevent overflow and breaking of layout 2025-07-03 18:25:04 +00:00
Jannat Patel
3d1a23576a fix: repositioned the search bar on members list 2025-07-03 19:40:09 +05:30
Jannat Patel
ed0e2e4bb5 fix: issue with roles on profile page 2025-07-03 19:26:07 +05:30
Jannat Patel
954d0a0637 fix: improved members list and form 2025-07-03 18:19:29 +05:30
Jannat Patel
f2c8788602 Merge pull request #1613 from Grumbled0rf/patch-1
Update README.md
2025-07-03 13:21:33 +05:30
Jannat Patel
49c63da27c Merge pull request #1614 from mahsem/patch-state
fix: state_translatability
2025-07-03 13:21:06 +05:30
Jannat Patel
24496d1856 Merge pull request #1617 from pateljannat/course-progress-summary
feat: course progress summary report
2025-07-03 13:20:41 +05:30
Jannat Patel
991ebe09a2 chore: linters 2025-07-03 13:09:45 +05:30
Jannat Patel
85da4f6d85 feat: course progress summary report 2025-07-03 13:02:57 +05:30
Jannat Patel
5f065db991 Merge pull request #1616 from pateljannat/issues-117
fix: when logo is updated from brand settings, update the login logo too
2025-07-02 16:56:20 +05:30
Manoj Prabhkaran D
ffb40586d7 Merge branch 'develop' into patch-1 2025-07-02 15:10:59 +04:00
Jannat Patel
fcfd87fd50 fix: when logo is updated from brand settings, update the login logo too 2025-07-02 16:16:49 +05:30
Jannat Patel
eb5b12aa7b Merge pull request #1615 from pateljannat/course-list-fetch
fix: page length issue on course list
2025-07-02 15:41:23 +05:30
Manoj Prabhkaran D
f6e2438744 docs(readme): add DNS configuration note to avoid 404 error during self-hosting 2025-07-02 13:43:10 +04:00
Jannat Patel
e3c7dc695d fix: page length issue on batch list 2025-07-02 15:01:33 +05:30
Jannat Patel
82d2025e6c fix: page length issue on course list 2025-07-02 15:00:53 +05:30
mahsem
91b82d78b8 fix: state _translatability 2025-07-02 11:03:33 +02:00
Jannat Patel
b97e792893 Merge pull request #1609 from pateljannat/track-video-watch-duration
feat: video watch time tracking
2025-07-02 10:53:56 +05:30
Jannat Patel
13ac5ec7dc fix: sidebar settings for programming exercises 2025-07-02 10:47:03 +05:30
Jannat Patel
199f880936 Merge pull request #1612 from addeeandra/fix-sidebar-settings-programming-exercise
feat: sidebar settings to toggle "Programming Exercise" menu
2025-07-02 10:33:47 +05:30
Jannat Patel
ed86c207ba Merge pull request #1610 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-01 19:18:51 +05:30
Jannat Patel
b4cf290f4d fix: allow backward seek but prevent forward seek 2025-07-01 19:16:13 +05:30
Jannat Patel
e526a6fd64 fix: moved sirebar settings to settings store 2025-07-01 17:38:15 +05:30
Jannat Patel
94cbbf169a feat: prevent skipping videos 2025-07-01 17:27:43 +05:30
Jannat Patel
2837ed16a7 feat: track watch time for youtube and vimeo 2025-07-01 16:55:55 +05:30
Aditya Chandra
68961deb6b revert: api.py 2025-07-01 12:52:52 +07:00
Aditya Chandra
ec54bfee98 fix: programming exercise's sidebar settings 2025-07-01 12:39:50 +07:00
Jannat Patel
385e97b76a chore: Serbian (Cyrillic) translations 2025-07-01 00:39:53 +05:30
Jannat Patel
cbd916877f chore: Italian translations 2025-07-01 00:39:51 +05:30
Jannat Patel
38586034cd chore: Vietnamese translations 2025-07-01 00:39:50 +05:30
Jannat Patel
62b3ba2bff chore: Dutch translations 2025-07-01 00:39:49 +05:30
Jannat Patel
dd470b61b5 chore: Czech translations 2025-07-01 00:39:47 +05:30
Jannat Patel
4fa92d2327 chore: Esperanto translations 2025-07-01 00:39:46 +05:30
Jannat Patel
6f6c2db66d chore: Chinese Simplified translations 2025-07-01 00:39:44 +05:30
Jannat Patel
e6348cfa20 chore: Serbian (Latin) translations 2025-07-01 00:39:43 +05:30
Jannat Patel
a006d1000a chore: Bosnian translations 2025-07-01 00:39:42 +05:30
Jannat Patel
4a575e642f chore: Croatian translations 2025-07-01 00:39:40 +05:30
Jannat Patel
93525bc577 chore: Thai translations 2025-07-01 00:39:39 +05:30
Jannat Patel
2cf0e9a723 chore: Persian translations 2025-07-01 00:39:36 +05:30
Jannat Patel
c32164bfea chore: Portuguese, Brazilian translations 2025-07-01 00:39:35 +05:30
Jannat Patel
714b0924e7 chore: Turkish translations 2025-07-01 00:39:34 +05:30
Jannat Patel
43079790a8 chore: Swedish translations 2025-07-01 00:39:33 +05:30
Jannat Patel
d03e61b625 chore: Russian translations 2025-07-01 00:39:31 +05:30
Jannat Patel
2d760112a3 chore: Portuguese translations 2025-07-01 00:39:30 +05:30
Jannat Patel
f46507ec72 chore: Polish translations 2025-07-01 00:39:28 +05:30
Jannat Patel
e9e10bdc93 chore: Hungarian translations 2025-07-01 00:39:27 +05:30
Jannat Patel
0386967a32 chore: German translations 2025-07-01 00:39:25 +05:30
Jannat Patel
4900fc8b88 chore: Arabic translations 2025-07-01 00:39:24 +05:30
Jannat Patel
99294b5643 chore: Spanish translations 2025-07-01 00:39:22 +05:30
Jannat Patel
eb12bcb83c chore: French translations 2025-07-01 00:39:21 +05:30
Jannat Patel
22a2e57642 fix: fetch stats when lesson is loaded 2025-06-30 20:00:13 +05:30
Jannat Patel
5eaae06ceb feat: video watch time tracking 2025-06-30 19:56:07 +05:30
Jannat Patel
ce7fc35349 Merge pull request #1606 from pateljannat/sidebar-toggle-issue
chore: upgraded frappe ui
2025-06-30 11:57:06 +05:30
Jannat Patel
8d4b5c83ae chore: upgraded frappe ui 2025-06-30 11:49:22 +05:30
Jannat Patel
cbd3c56ca0 Merge pull request #1605 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-30 11:42:20 +05:30
Jannat Patel
be6dad1424 Merge pull request #1603 from frappe/pot_develop_2025-06-27
chore: update POT file
2025-06-30 11:42:07 +05:30
Jannat Patel
298452fa7b Merge pull request #1602 from pateljannat/negative-marking-in-quiz
feat: negative marking in quiz
2025-06-30 11:41:53 +05:30
Jannat Patel
4abbd7c35c chore: Bosnian translations 2025-06-30 00:27:08 +05:30
Jannat Patel
c2f51c51ab chore: Croatian translations 2025-06-30 00:27:07 +05:30
Jannat Patel
255cff6664 chore: Turkish translations 2025-06-30 00:27:04 +05:30
Jannat Patel
8a9578bb0a chore: German translations 2025-06-30 00:26:59 +05:30
Jannat Patel
8831f6cecc chore: Arabic translations 2025-06-30 00:26:58 +05:30
Jannat Patel
f3daa7e48b chore: Spanish translations 2025-06-30 00:26:56 +05:30
Jannat Patel
6163597958 chore: French translations 2025-06-30 00:26:55 +05:30
frappe-pr-bot
f9e1222065 chore: update POT file 2025-06-27 16:04:48 +00:00
Jannat Patel
7d85de7c6c fix: set default marks to cut as 1 2025-06-27 20:00:57 +05:30
Jannat Patel
cf452c2300 feat: negative marking in quiz 2025-06-27 19:58:35 +05:30
Jannat Patel
72bd1d548d Merge pull request #1600 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-27 16:51:01 +05:30
Jannat Patel
4556f4dee6 chore: Thai translations 2025-06-26 23:43:56 +05:30
Jannat Patel
3dfbd3165a chore: German translations 2025-06-26 23:43:54 +05:30
Jannat Patel
02b8e02131 Merge pull request #1599 from pateljannat/issues-116
fix: misc issues
2025-06-26 16:53:06 +05:30
Jannat Patel
087ded9f9e chore: removed console 2025-06-26 16:37:05 +05:30
Jannat Patel
21f122ee82 fix: misc issues 2025-06-26 16:35:03 +05:30
Jannat Patel
d60a7e8c94 Merge pull request #1593 from pateljannat/programming-exercises
feat: programming exercises
2025-06-26 13:05:22 +05:30
Jannat Patel
b8981c249f feat: livecode settings 2025-06-25 19:57:07 +05:30
Jannat Patel
e71275a0dc feat: javascript exercises 2025-06-25 12:15:27 +05:30
Jannat Patel
4fb0db7a1e fix: test case with no input issue 2025-06-24 12:22:02 +05:30
Jannat Patel
1e9beedc77 Merge pull request #1591 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-24 09:50:23 +05:30
Jannat Patel
4a4a0653ef chore: Serbian (Cyrillic) translations 2025-06-23 22:25:04 +05:30
Jannat Patel
c80a900277 chore: Serbian (Latin) translations 2025-06-23 22:25:02 +05:30
Jannat Patel
6fb0394d96 Merge pull request #1588 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-23 18:55:28 +05:30
Jannat Patel
a6a7712039 chore: Serbian (Latin) translations 2025-06-22 22:14:14 +05:30
Jannat Patel
dd0687ba29 chore: Serbian (Cyrillic) translations 2025-06-21 22:08:07 +05:30
Jannat Patel
9cb87a5333 chore: Italian translations 2025-06-21 22:08:06 +05:30
Jannat Patel
8ec93d84a0 chore: Vietnamese translations 2025-06-21 22:08:04 +05:30
Jannat Patel
1d38715db9 chore: Dutch translations 2025-06-21 22:08:03 +05:30
Jannat Patel
6225c4eb35 chore: Czech translations 2025-06-21 22:08:02 +05:30
Jannat Patel
e58ce2fbe6 chore: Esperanto translations 2025-06-21 22:08:00 +05:30
Jannat Patel
8881d62e78 chore: Chinese Simplified translations 2025-06-21 22:07:59 +05:30
Jannat Patel
effb2a1265 chore: Serbian (Latin) translations 2025-06-21 22:07:58 +05:30
Jannat Patel
ab387473b5 chore: Bosnian translations 2025-06-21 22:07:56 +05:30
Jannat Patel
3cf6079b70 chore: Croatian translations 2025-06-21 22:07:55 +05:30
Jannat Patel
53c655bb53 chore: Thai translations 2025-06-21 22:07:54 +05:30
Jannat Patel
87952463c2 chore: Persian translations 2025-06-21 22:07:52 +05:30
Jannat Patel
3a8a63a49a chore: Portuguese, Brazilian translations 2025-06-21 22:07:51 +05:30
Jannat Patel
debe115044 chore: Turkish translations 2025-06-21 22:07:50 +05:30
Jannat Patel
554d2808fd chore: Swedish translations 2025-06-21 22:07:49 +05:30
Jannat Patel
12b2c89a25 chore: Russian translations 2025-06-21 22:07:47 +05:30
Jannat Patel
a66fc3a07e chore: Portuguese translations 2025-06-21 22:07:46 +05:30
Jannat Patel
7b3705cab0 chore: Polish translations 2025-06-21 22:07:45 +05:30
Jannat Patel
8e99e5f5e8 chore: Hungarian translations 2025-06-21 22:07:43 +05:30
Jannat Patel
c5ba5370bb chore: German translations 2025-06-21 22:07:42 +05:30
Jannat Patel
464dec9810 chore: Arabic translations 2025-06-21 22:07:41 +05:30
Jannat Patel
c2e2ec8803 chore: Spanish translations 2025-06-21 22:07:39 +05:30
Jannat Patel
37378e2360 chore: French translations 2025-06-21 22:07:38 +05:30
Jannat Patel
678385d90c Merge pull request #1586 from frappe/pot_develop_2025-06-20
chore: update POT file
2025-06-20 22:25:10 +05:30
frappe-pr-bot
4c461f087f chore: update POT file 2025-06-20 16:04:51 +00:00
Jannat Patel
88a2b69980 feat: exercise form and submission list 2025-06-20 19:59:10 +05:30
Jannat Patel
1f57792da7 Merge pull request #1582 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-19 22:30:20 +05:30
Jannat Patel
9bb4c45a23 feat: programming exercise submission 2025-06-19 14:47:52 +05:30
Jannat Patel
75fd19f491 chore: Serbian (Cyrillic) translations 2025-06-18 21:10:25 +05:30
Jannat Patel
0ac16bdeb7 chore: Italian translations 2025-06-18 21:10:23 +05:30
Jannat Patel
223ee41e10 Merge pull request #1578 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-18 12:15:27 +05:30
Jannat Patel
c126ded82e feat: allow Jammu and Kashmir as state 2025-06-18 11:07:58 +05:30
Jannat Patel
0edf78b7fd feat: programming exercises 2025-06-18 11:06:44 +05:30
Jannat Patel
5af3580987 Merge pull request #1580 from pateljannat/lesson-editor-improvements
fix: lesson editor fixes
2025-06-17 17:04:14 +05:30
Jannat Patel
343cb6f97a fix: removed unused import 2025-06-17 16:46:36 +05:30
Jannat Patel
023c8ac13e fix: lesson editor fixes 2025-06-17 16:38:42 +05:30
Jannat Patel
c385eed795 chore: Vietnamese translations 2025-06-16 20:59:31 +05:30
Jannat Patel
ee5fdd789f chore: Dutch translations 2025-06-16 20:59:29 +05:30
Jannat Patel
df1e400f4e chore: Czech translations 2025-06-16 20:59:28 +05:30
Jannat Patel
6c9c298478 chore: Esperanto translations 2025-06-16 20:59:27 +05:30
Jannat Patel
7106ee150d chore: Chinese Simplified translations 2025-06-16 20:59:26 +05:30
Jannat Patel
03e2287f80 chore: Serbian (Latin) translations 2025-06-16 20:59:24 +05:30
Jannat Patel
2edcd41e24 chore: Bosnian translations 2025-06-16 20:59:23 +05:30
Jannat Patel
0fe043bd99 chore: Croatian translations 2025-06-16 20:59:21 +05:30
Jannat Patel
6686f5240d chore: Thai translations 2025-06-16 20:59:20 +05:30
Jannat Patel
2936facf0f chore: Persian translations 2025-06-16 20:59:18 +05:30
Jannat Patel
cc208f2c43 chore: Portuguese, Brazilian translations 2025-06-16 20:59:17 +05:30
Jannat Patel
9a0fc231e5 chore: Turkish translations 2025-06-16 20:59:16 +05:30
Jannat Patel
bfc0ae62ec chore: Swedish translations 2025-06-16 20:59:14 +05:30
Jannat Patel
5e7d8d97f2 chore: Russian translations 2025-06-16 20:59:13 +05:30
Jannat Patel
70ceb16ed6 chore: Portuguese translations 2025-06-16 20:59:11 +05:30
Jannat Patel
f162fa639f chore: Polish translations 2025-06-16 20:59:10 +05:30
Jannat Patel
f000c72546 chore: Hungarian translations 2025-06-16 20:59:08 +05:30
Jannat Patel
32c01f931c chore: German translations 2025-06-16 20:59:07 +05:30
Jannat Patel
d0121e2b9d chore: Arabic translations 2025-06-16 20:59:06 +05:30
Jannat Patel
1caab8ce1d chore: Spanish translations 2025-06-16 20:59:04 +05:30
Jannat Patel
878be435a1 chore: French translations 2025-06-16 20:59:02 +05:30
Frappe PR Bot
6a68ae989e chore(release): Bumped to Version 2.31.0 2025-06-16 11:49:38 +00:00
Jannat Patel
00993da781 Merge pull request #1577 from pateljannat/issues-115
fix: misc issues
2025-06-16 17:09:33 +05:30
Jannat Patel
e9ef67e402 chore: regenerated yarn lock 2025-06-16 16:55:31 +05:30
Jannat Patel
83ebfececf feat: edit related courses from frontend 2025-06-16 15:13:30 +05:30
Jannat Patel
ec8bf6251f Merge branch 'develop' of https://github.com/frappe/lms into issues-115 2025-06-16 13:07:26 +05:30
Jannat Patel
1b2874b3a5 Merge pull request #1565 from OsafAliSayed/related_courses
Feat: Related courses
2025-06-16 13:07:18 +05:30
Jannat Patel
0ac1053a71 Merge pull request #1575 from frappe/pot_develop_2025-06-13
chore: update POT file
2025-06-16 12:55:02 +05:30
Jannat Patel
224d270952 Merge pull request #1572 from harshpwctech/develop
feat: Embedding for Bunny Stream
2025-06-16 12:54:49 +05:30
Jannat Patel
c6137545cd ci: verify yarn lock file 2025-06-16 12:53:53 +05:30
Jannat Patel
335417f9f4 fix: persona form role issue 2025-06-16 12:44:29 +05:30
Jannat Patel
cb797223ed fix: time markers on video slider for quiz 2025-06-16 12:07:39 +05:30
Jannat Patel
3a2a0313ac fix: show an intermediate dialog informing users of the quiz if its in between video 2025-06-16 11:22:29 +05:30
Jannat Patel
e221a5a73a Merge branch 'develop' of https://github.com/frappe/lms into issues-115 2025-06-16 10:47:47 +05:30
frappe-pr-bot
2b7aaf095f chore: update POT file 2025-06-13 16:04:26 +00:00
Jannat Patel
6f01e7b8d8 fix: job count 2025-06-13 20:33:51 +05:30
Jannat Patel
d594419200 feat: show live class joining and leaving time in attendance list 2025-06-12 23:18:35 +05:30
Jannat Patel
bf50e3f898 Merge pull request #1571 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-11 20:05:43 +05:30
safe user
d434f1781f feat: Embedding for Bunny Stream 2025-06-11 06:53:26 +00:00
safe user
3f311a45ef feat: Embedding for Bunny Stream 2025-06-11 06:42:29 +00:00
Jannat Patel
9293b7796e chore: Serbian (Latin) translations 2025-06-11 03:33:13 +05:30
OsafAliSayed
b1e7883526 fix(relatedCourses): remove loading component 2025-06-10 18:03:43 +00:00
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
OsafAliSayed
212800155b style(linter): apply linting fixes 2025-06-09 06:13:21 +00:00
OsafAliSayed
c241bf2104 feat(related-courses): add related courses component 2025-06-09 06:13:21 +00:00
OsafAliSayed
bda61f32f3 feat(related-courses): add related courses frontend 2025-06-09 06:11:40 +00:00
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
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
de240e40a5 Merge pull request #1507 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-16 12:01:07 +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
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
d286df649e Merge pull request #1490 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-07 12:56:56 +05:30
Jannat Patel
e0cbc247b2 Merge pull request #1485 from pateljannat/release-conflicts
chore: merge to 'main'
2025-05-06 13:10:08 +05:30
Jannat Patel
a2c8a82559 chore: merged conflicts 2025-05-06 12:59:22 +05:30
Jannat Patel
8b91323705 Merge pull request #1457 from pateljannat/vimeo
fix: allow fullscreen on vimeo
2025-04-21 17:32:12 +05:30
Jannat Patel
89fdbf5660 test: find the course image label and attach course image to its sibling input 2025-04-21 17:10:33 +05:30
Jannat Patel
7ed5dfdb8f fix: allow fullscreen on video and adjust video height on mobile devices 2025-04-21 16:34:34 +05:30
Jannat Patel
824c65eb38 Merge pull request #1440 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-16 18:55:21 +05:30
Jannat Patel
e43eeeba4a Merge pull request #1423 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-10 16:01:54 +05:30
Jannat Patel
9e2c7cc145 Merge pull request #1417 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-09 15:17:25 +05:30
Jannat Patel
989598b9cd Merge pull request #1398 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-27 09:44:00 +05:30
Jannat Patel
6a41942de6 Merge pull request #1384 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-20 13:04:16 +05:30
Jannat Patel
d263072aca Merge pull request #1373 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-12 11:10:48 +05:30
Jannat Patel
78c8467bf6 Merge pull request #1361 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-05 18:33:33 +05:30
Jannat Patel
084908bd04 Merge pull request #1352 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-03 15:38:08 +05:30
Jannat Patel
039a775ce4 Merge pull request #1340 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-26 10:18:26 +05:30
Jannat Patel
dd9e80f067 Merge pull request #1326 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-19 11:06:43 +05:30
Jannat Patel
a3a2af948e Merge pull request #1303 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-14 18:07:10 +05:30
Jannat Patel
0bedf3ea59 Merge pull request #1264 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-22 12:59:34 +05:30
Jannat Patel
1775ac4803 Merge pull request #1247 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-15 11:24:49 +05:30
Jannat Patel
ae1a615863 Merge pull request #1237 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-09 11:01:37 +05:30
Jannat Patel
a6ef1b8902 Merge pull request #1220 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-02 20:24:58 +05:30
Jannat Patel
94d17b81d4 Merge pull request #1197 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-18 20:30:52 +05:30
Jannat Patel
44a63d9cec Merge pull request #1180 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-13 12:27:07 +05:30
Jannat Patel
e2b4b5a57e Merge pull request #1164 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-06 11:05:49 +05:30
Jannat Patel
ec30aa323e Merge pull request #1155 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-29 17:05:30 +05:30
Jannat Patel
95e9087c6e Merge pull request #1151 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-25 15:06:00 +05:30
Jannat Patel
db38099557 Merge pull request #1143 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-20 13:30:06 +05:30
Jannat Patel
164d5cdec9 Merge pull request #1130 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-13 11:53:57 +05:30
Jannat Patel
c6b1076092 Merge pull request #1099 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-07 09:51:07 +05:30
Jannat Patel
6aebe856da Merge pull request #1087 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-31 12:15:12 +05:30
Jannat Patel
4737551918 Merge pull request #1075 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-23 13:00:37 +05:30
Jannat Patel
c2cb79f700 Merge pull request #1067 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-17 12:33:35 +05:30
Jannat Patel
d7c05984be Merge pull request #1048 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-09 22:11:23 +05:30
Jannat Patel
55429e2f03 Merge pull request #1036 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-02 12:30:46 +05:30
Jannat Patel
25ffe8b0e4 Merge pull request #1029 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-25 11:47:14 +05:30
Jannat Patel
303a9d1110 Merge pull request #1020 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-18 10:21:11 +05:30
Jannat Patel
de8c907c51 Merge pull request #1013 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-11 11:09:55 +05:30
Jannat Patel
0fd1cabd60 Merge pull request #1003 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-04 10:36:05 +05:30
Jannat Patel
8dd480735c Merge pull request #996 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-28 11:24:59 +05:30
Jannat Patel
676f1a1f0e Merge pull request #984 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-21 10:48:23 +05:30
Jannat Patel
ce75422126 Merge pull request #966 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-14 11:24:10 +05:30
Jannat Patel
3a097d6b15 Merge pull request #956 from frappe/develop
chore: Merge develop into main
2024-08-06 11:27:00 +05:30
Jannat Patel
9de1bf1020 Merge pull request #954 from frappe/develop
chore: Merge develop into main
2024-08-05 14:47:45 +05:30
Jannat Patel
93e5cf1c25 Merge pull request #952 from frappe/develop
chore: Merge develop to main
2024-08-05 12:22:05 +05:30
Jannat Patel
6e2376570b Merge pull request #949 from frappe/develop
chore: Merge develop to main
2024-08-01 17:16:22 +05:30
Jannat Patel
b20c4bf197 Merge pull request #948 from frappe/develop
chore: Merge develop to main
2024-07-31 16:33:43 +05:30
Jannat Patel
6ae1d92033 Merge pull request #925 from frappe/develop
chore: merge `develop` into `main`
2024-07-11 09:11:50 +05:30
219 changed files with 86488 additions and 16682 deletions

View File

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

View File

@@ -1,7 +1,7 @@
name: Create weekly release
on:
schedule:
- cron: '30 4 15 * *'
- cron: '30 3 * * 3'
workflow_dispatch:
jobs:

View File

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

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

@@ -8,7 +8,7 @@ describe("Course Creation", () => {
cy.closeOnboardingModal();
// Create a course
cy.get("button").contains("New").click();
cy.get("button").contains("Create").click();
cy.wait(500);
cy.url().should("include", "/courses/new/edit");
@@ -107,7 +107,7 @@ describe("Course Creation", () => {
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".course-image")
cy.get(".bg-cover")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
@@ -140,6 +140,7 @@ describe("Course Creation", () => {
);
// Add Discussion
cy.get("span").contains("Community").click();
cy.button("New Question").click();
cy.wait(500);
cy.get("[id^=headlessui-dialog-panel-").within(() => {

View File

@@ -72,8 +72,15 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
Cypress.Commands.add("closeOnboardingModal", () => {
cy.wait(500);
cy.get('[class*="z-50"]')
.find('button:has(svg[class*="feather-x"])')
.realClick();
cy.wait(1000);
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.");
}
});
});

1
frappe-ui Submodule

Submodule frappe-ui added at 333dce1a4d

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

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

View File

@@ -19,6 +19,10 @@ declare module 'vue' {
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
BadgeAssignmentForm: typeof import('./src/components/Settings/BadgeAssignmentForm.vue')['default']
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
@@ -27,17 +31,21 @@ 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']
ChildTable: typeof import('./src/components/Controls/ChildTable.vue')['default']
Code: typeof import('./src/components/Controls/Code.vue')['default']
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
@@ -48,16 +56,17 @@ declare module 'vue' {
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/EmailTemplates.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']
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
JobCard: typeof import('./src/components/JobCard.vue')['default']
@@ -65,37 +74,45 @@ 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']
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/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']
RelatedCourses: typeof import('./src/components/RelatedCourses.vue')['default']
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingDetails: typeof import('./src/components/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']
Tags: typeof import('./src/components/Tags.vue')['default']
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
}
}

View File

@@ -10,6 +10,10 @@
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.2.1",
"@editorjs/checklist": "^1.6.0",
"@editorjs/code": "^2.9.0",
"@editorjs/editorjs": "^2.29.0",
@@ -24,10 +28,10 @@
"ace-builds": "^1.36.2",
"apexcharts": "^4.3.0",
"chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"codemirror": "^6.0.1",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.147",
"frappe-ui": "0.1.173",
"highlight.js": "^11.11.1",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0",
@@ -35,9 +39,11 @@
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2",
"tailwindcss": "3.4.15",
"thememirror": "^2.0.1",
"typescript": "^5.7.2",
"vue": "^3.4.23",
"vue-chartjs": "^5.3.0",
"vue-codemirror": "^6.1.1",
"vue-draggable-next": "^2.2.1",
"vue-router": "^4.0.12",
"vue3-apexcharts": "^1.8.0",

BIN
frontend/public/Remove.mp4 Normal file

Binary file not shown.

View File

@@ -1,7 +1,9 @@
<template>
<FrappeUIProvider>
<Layout>
<router-view />
<div class="text-base">
<router-view />
</div>
</Layout>
<Dialogs />
</FrappeUIProvider>
@@ -9,16 +11,19 @@
<script setup>
import { FrappeUIProvider } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs'
import { computed, 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 { usersStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { posthogSettings } from '@/telemetry'
const screenSize = useScreenSize()
const router = useRouter()
const noSidebar = ref(false)
const { userResource } = usersStore()
router.beforeEach((to, from, next) => {
if (to.query.fromLesson || to.path === '/persona') {
@@ -42,6 +47,11 @@ const Layout = computed(() => {
onUnmounted(() => {
noSidebar.value = false
stopSession()
})
watch(userResource, () => {
if (userResource.data) {
posthogSettings.reload()
}
})
</script>

View File

@@ -181,8 +181,17 @@
import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue'
import { getSidebarLinks } from '../utils'
import {
ref,
onMounted,
inject,
watch,
reactive,
markRaw,
h,
onUnmounted,
} from 'vue'
import { getSidebarLinks } from '@/utils'
import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
@@ -216,7 +225,7 @@ import {
IntermediateStepModal,
} from 'frappe-ui/frappe'
const { user, sidebarSettings } = sessionStore()
const { user } = sessionStore()
const { userResource } = usersStore()
let sidebarStore = useSidebar()
const socket = inject('$socket')
@@ -227,6 +236,7 @@ const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null)
const settingsStore = useSettings()
const { sidebarSettings } = settingsStore
const showOnboarding = ref(false)
const showIntermediateModal = ref(false)
const currentStep = ref({})
@@ -304,7 +314,7 @@ const addNotifications = () => {
const addQuizzes = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
sidebarLinks.value.splice(4, 0, {
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
@@ -320,7 +330,7 @@ const addQuizzes = () => {
const addAssignments = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
sidebarLinks.value.splice(5, 0, {
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
@@ -334,6 +344,22 @@ const addAssignments = () => {
}
}
const addProgrammingExercises = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.splice(3, 0, {
label: 'Programming Exercises',
icon: 'Code',
to: 'ProgrammingExercises',
activeFor: [
'ProgrammingExercises',
'ProgrammingExerciseForm',
'ProgrammingExerciseSubmissions',
'ProgrammingExerciseSubmission',
],
})
}
}
const addPrograms = () => {
let activeFor = ['Programs', 'ProgramForm']
let index = 1
@@ -617,6 +643,7 @@ watch(userResource, () => {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addPrograms()
addProgrammingExercises()
addQuizzes()
addAssignments()
setUpOnboarding()
@@ -626,4 +653,8 @@ watch(userResource, () => {
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
</script>

View File

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

View File

@@ -40,7 +40,7 @@
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'assessment_type'">
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
{{ getAssessmentTypeLabel(row[column.key]) }}
</div>
<div v-else-if="column.key == 'title'">
{{ row[column.key] }}
@@ -172,6 +172,24 @@ const getRowRoute = (row) => {
},
}
}
} else if (row.assessment_type == 'LMS Programming Exercise') {
if (row.submission) {
return {
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment_name,
submissionID: row.submission.name,
},
}
} else {
return {
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment_name,
submissionID: 'new',
},
}
}
} else {
return {
name: 'QuizPage',
@@ -213,7 +231,7 @@ const getAssessmentColumns = () => {
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
if (status === 'Pass' || status === 'Passed') {
return 'green'
} else if (status === 'Not Graded') {
return 'orange'
@@ -221,4 +239,14 @@ const getStatusTheme = (status) => {
return 'red'
}
}
const getAssessmentTypeLabel = (type) => {
if (type == 'LMS Assignment') {
return __('Assignment')
} else if (type == 'LMS Quiz') {
return __('Quiz')
} else if (type == 'LMS Programming Exercise') {
return __('Programming Exercise')
}
}
</script>

View File

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

View File

@@ -106,7 +106,6 @@ const courses = createResource({
params: {
batch: props.batch,
},
cache: ['batchCourses', props.batchName],
auto: true,
})

View File

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

View File

@@ -65,6 +65,10 @@
}"
>
<Button variant="solid" class="w-full mt-4">
<template #prefix>
<Settings v-if="isModerator" class="size-4 stroke-1.5" />
<LogIn v-else class="size-4 stroke-1.5" />
</template>
<span>
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
</span>
@@ -85,6 +89,9 @@
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Register Now') }}
</span>
@@ -100,6 +107,9 @@
"
@click="enrollInBatch()"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Enroll Now') }}
</Button>
<router-link
@@ -112,6 +122,9 @@
}"
>
<Button class="w-full mt-2">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
@@ -122,8 +135,17 @@
</template>
<script setup>
import { inject, computed } from 'vue'
import { Badge, Button, createResource, toast } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { Button, createResource, toast } from 'frappe-ui'
import {
BookOpen,
Clock,
CreditCard,
Globe,
GraduationCap,
LogIn,
Pencil,
Settings,
} from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
height: height,
}"
>
<span class="text-xs text-ink-gray-7" v-if="label">
<span class="text-xs text-ink-gray-7 mb-1" v-if="label">
{{ label }}
</span>
<div

View File

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

View File

@@ -12,6 +12,7 @@
:variant="attrs.variant"
:placeholder="attrs.placeholder"
:filterable="false"
:readonly="attrs.readonly"
>
<template #target="{ open, togglePopover }">
<slot name="target" v-bind="{ open, togglePopover }" />

View File

@@ -55,9 +55,10 @@
</div>
</li>
</ComboboxOption>
<div class="h-10"></div>
<div
v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[98%] pt-2 bg-white border-t"
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
>
<Button
variant="ghost"

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<div class="border-2 rounded-md min-w-80">
<div class="border-2 rounded-md min-w-80 max-w-sm">
<iframe
v-if="course.data.video_link"
:src="video_link"
@@ -26,6 +26,9 @@
}"
>
<Button variant="solid" size="md" class="w-full">
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Continue Learning') }}
</span>
@@ -44,6 +47,9 @@
}"
>
<Button variant="solid" size="md" class="w-full">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Buy this course') }}
</span>
@@ -57,12 +63,15 @@
{{ __('Contact the Administrator to enroll for this course.') }}
</Badge>
<Button
v-else
v-else-if="!user.data?.is_moderator && !is_instructor()"
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Start Learning') }}
</span>
@@ -74,8 +83,22 @@
class="w-full mt-2"
size="md"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certificate') }}
</Button>
<Button
v-if="user.data?.is_moderator || is_instructor()"
class="w-full mt-2"
size="md"
@click="showProgressSummary"
>
<template #prefix>
<TrendingUp class="size-4 stroke-1.5" />
{{ __('Progress Summary') }}
</template>
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
@@ -86,6 +109,9 @@
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
@@ -142,18 +168,34 @@
</div>
</div>
</div>
<CourseProgressSummary
v-model="showProgressModal"
:courseName="course.data.name"
:enrollments="course.data.enrollments"
/>
</template>
<script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { Badge, Button, createResource, toast } from 'frappe-ui'
import {
BookOpen,
BookText,
CreditCard,
GraduationCap,
Pencil,
Star,
TrendingUp,
Users,
} from 'lucide-vue-next'
import { computed, inject, ref } from 'vue'
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue'
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
const router = useRouter()
const user = inject('$user')
const showProgressModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
@@ -175,15 +217,11 @@ function enrollStudent() {
toast.success(__('You need to login first to enroll for this course'))
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 1000)
}, 500)
} else {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
course: props.course.data.name,
})
enrollStudentResource
.submit({
course: props.course.data.name,
})
.then(() => {
capture('enrolled_in_course', {
course: props.course.data.name,
@@ -198,7 +236,11 @@ function enrollStudent() {
lessonNumber: 1,
},
})
}, 2000)
}, 1000)
})
.catch((err) => {
toast.warning(__(err.messages?.[0] || err))
console.error(err)
})
}
}
@@ -246,4 +288,8 @@ const fetchCertificate = () => {
member: user.data?.name,
})
}
const showProgressSummary = () => {
showProgressModal.value = true
}
</script>

View File

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

View File

@@ -23,119 +23,135 @@
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
}"
>
<Disclosure
v-slot="{ open }"
v-for="(chapter, index) in outline.data"
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
<Draggable
:list="outline.data"
:disabled="!allowEdit"
item-key="name"
group="chapters"
@end="updateChapterOrder"
>
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 1,
}"
class="h-4 w-4 text-ink-gray-9 stroke-1"
/>
<div
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
>
{{ chapter.title }}
</div>
<div class="flex ml-auto space-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-ink-gray-9 invisible group-hover:visible"
/>
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<Trash2
v-if="allowEdit"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-ink-red-3 invisible group-hover:visible"
/>
</Tooltip>
</div>
</DisclosureButton>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
v-if="!chapter.is_scorm_package"
:list="chapter.lessons"
:disabled="!allowEdit"
item-key="name"
group="items"
@end="updateOutline"
:data-chapter="chapter.name"
>
<template #item="{ element: lesson }">
<div
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
:class="
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
"
<template #item="{ element: chapter, index }">
<div class="chapter-item">
<Disclosure
v-slot="{ open }"
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
>
<DisclosureButton
ref=""
class="flex items-center w-full p-2 group"
>
<router-link
:to="{
name: allowEdit ? 'LessonForm' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
lessonNumber: lesson.number.split('.')[1],
},
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 1,
}"
class="h-4 w-4 text-ink-gray-9 stroke-1"
/>
<div
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
>
<div class="flex items-center text-sm leading-5 group">
<MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 stroke-1 mr-2"
{{ chapter.title }}
</div>
<div class="flex ml-auto space-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-ink-gray-9 invisible group-hover:visible"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
/>
{{ lesson.title }}
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<Trash2
v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)"
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-ink-red-3 invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-700 ml-2"
/>
</div>
</router-link>
</div>
</template>
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link
v-if="!chapter.is_scorm_package"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: chapter.idx,
lessonNumber: chapter.lessons.length + 1,
},
}"
>
<Button>
{{ __('Add Lesson') }}
</Button>
</router-link>
</Tooltip>
</div>
</DisclosureButton>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
v-if="!chapter.is_scorm_package"
:list="chapter.lessons"
:disabled="!allowEdit"
item-key="name"
group="items"
@end="updateOutline"
:data-chapter="chapter.name"
>
<template #item="{ element: lesson }">
<div
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
:class="
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
"
>
<router-link
:to="{
name: allowEdit ? 'LessonForm' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
lessonNumber: lesson.number.split('.')[1],
},
}"
>
<div class="flex items-center text-sm leading-5 group">
<MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 stroke-1 mr-2"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
/>
{{ lesson.title }}
<Trash2
v-if="allowEdit"
@click.prevent="
trashLesson(lesson.name, chapter.name)
"
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-700 ml-2"
/>
</div>
</router-link>
</div>
</template>
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link
v-if="!chapter.is_scorm_package"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: chapter.idx,
lessonNumber: chapter.lessons.length + 1,
},
}"
>
<Button>
{{ __('Add Lesson') }}
</Button>
</router-link>
</div>
</DisclosurePanel>
</Disclosure>
</div>
</DisclosurePanel>
</Disclosure>
</template>
</Draggable>
</div>
</div>
<ChapterModal
@@ -148,7 +164,7 @@
</template>
<script setup>
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue'
import { getCurrentInstance, inject, ref, watch } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
@@ -197,13 +213,22 @@ const props = defineProps({
const outline = createResource({
url: 'lms.lms.utils.get_course_outline',
cache: ['course_outline', props.courseName],
params: {
course: props.courseName,
progress: props.getProgress,
makeParams() {
return {
course: props.courseName,
progress: props.getProgress,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
outline.reload()
}
)
const deleteLesson = createResource({
url: 'lms.lms.api.delete_lesson',
makeParams(values) {
@@ -233,6 +258,20 @@ const updateLessonIndex = createResource({
},
})
const updateChapterIndex = createResource({
url: 'lms.lms.api.update_chapter_index',
makeParams(values) {
return {
chapter: values.chapter,
course: values.course,
idx: values.idx,
}
},
onSuccess() {
toast.success(__('Chapter moved successfully'))
},
})
const trashLesson = (lessonName, chapterName) => {
$dialog({
title: __('Delete this lesson?'),
@@ -278,6 +317,14 @@ const updateOutline = (e) => {
})
}
const updateChapterOrder = (e) => {
updateChapterIndex.submit({
chapter: e.item.__draggable_context.element.name,
course: props.courseName,
idx: e.newIndex,
})
}
const deleteChapter = createResource({
url: 'lms.lms.api.delete_chapter',
makeParams(values) {

View File

@@ -64,7 +64,7 @@
<script setup>
import { Star } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui'
import { computed, ref, inject } from 'vue'
import { watch, ref, inject } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import ReviewModal from '@/components/Modals/ReviewModal.vue'
@@ -101,12 +101,21 @@ const hasReviewed = createResource({
const reviews = createResource({
url: 'lms.lms.utils.get_reviews',
cache: ['course_reviews', props.courseName],
params: {
course: props.courseName,
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
reviews.reload()
}
)
const showReviewModal = ref(false)
function openReviewModal() {

View File

@@ -32,13 +32,13 @@
"
:options="[
{
label: 'Edit',
label: __('Edit'),
onClick() {
reply.editable = true
},
},
{
label: 'Delete',
label: __('Delete'),
onClick() {
deleteReply(reply)
},
@@ -94,10 +94,10 @@
</template>
<script setup>
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import { timeAgo } from '../utils'
import { timeAgo } from '@/utils'
import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted } from 'vue'
import { ref, inject, onMounted, onUnmounted } from 'vue'
const showTopics = defineModel('showTopics')
const newReply = ref('')
@@ -251,4 +251,10 @@ const deleteReply = (reply) => {
}
)
}
onUnmounted(() => {
socket.off('publish_message')
socket.off('update_message')
socket.off('delete_message')
})
</script>

View File

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

View File

@@ -1,132 +0,0 @@
<template>
<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">
{{ __(label) }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div> -->
</div>
<div class="flex item-center space-x-2">
<FormControl
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
/>
<Button @click="() => (showForm = !showForm)">
<template #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>
<!-- Form to add new member -->
<div v-if="showForm" class="flex items-center space-x-2 my-4">
<FormControl
v-model="email"
:placeholder="__('Email')"
type="email"
class="w-full"
/>
<Button @click="addEvaluator()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<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>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui'
import { ref, watch } from 'vue'
import { Plus, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
const show = defineModel('show')
const search = ref('')
const showForm = ref(false)
const email = ref('')
const router = useRouter()
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
show: {
type: Boolean,
},
})
const evaluators = createResource({
url: 'frappe.client.get_list',
makeParams: () => {
return {
doctype: 'Course Evaluator',
fields: ['evaluator', 'full_name', 'user_image', 'username'],
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
}
},
auto: true,
})
const addEvaluator = () => {
call('lms.lms.api.add_an_evaluator', {
email: email.value,
}).then((data) => {
showForm.value = false
email.value = ''
evaluators.reload()
})
}
watch(search, () => {
evaluators.reload()
})
const openProfile = (username) => {
show.value = false
router.push({
name: 'Profile',
params: {
username: username,
},
})
}
</script>

View File

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

View File

@@ -15,60 +15,18 @@
</div>
</div>
<div class="space-y-2">
<div class="space-y-2" v-for="(item, key) in contentMap" :key="key">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('quiz')"
@click="openHelpDialog(key)"
>
<span>
{{ __('How to add a Quiz?') }}
{{ __(item.title) }}
</span>
<Info class="w-3 h-3 text-ink-gray-7" />
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('upload')"
>
<span class="leading-5">
{{ __(contentMap['upload']) }}
</span>
<Info class="w-3 h-3 text-ink-gray-7" />
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('youtube')"
>
<span>
{{ __(contentMap['youtube']) }}
</span>
<Info class="w-3 h-3 text-ink-gray-7" />
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'Copy the URL of the video from YouTube and paste it in the editor.'
)
}}
{{ __(item.description) }}
</div>
</div>
</div>
@@ -83,14 +41,31 @@ const showExplanation = ref(false)
const type = ref(null)
const title = ref(null)
const contentMap = {
quiz: 'How to add a Quiz?',
upload: 'How to upload content from your system?',
youtube: 'How to add a YouTube Video?',
quiz: {
title: 'How to add a Quiz?',
description:
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.',
},
upload: {
title: 'How to upload content from your system?',
description:
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.',
},
youtube: {
title: 'How to add a YouTube Video?',
description:
'Copy the URL of the video from YouTube and paste it in the editor.',
},
remove: {
title: 'How to remove an embed?',
description:
'To remove an embed like YouTube or Vimeo, put your cursor on the line below the embed, then drag your mouse cursor upwards to select the embed. Once the embed is selected press BackSpace.',
},
}
const openHelpDialog = (contentType) => {
type.value = contentType
title.value = contentMap[contentType]
title.value = contentMap[contentType].title
showExplanation.value = true
}
</script>

View File

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

View File

@@ -54,15 +54,17 @@
</div>
</template>
<script setup>
import { getSidebarLinks } from '../utils'
import { getSidebarLinks } from '@/utils'
import { useRouter } from 'vue-router'
import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session'
import { useSettings } from '@/stores/settings'
import { usersStore } from '@/stores/user'
import * as icons from 'lucide-vue-next'
const { logout, user, sidebarSettings } = sessionStore()
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const { sidebarSettings } = useSettings()
const router = useRouter()
let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks())

View File

@@ -25,6 +25,7 @@
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Reply To') }}
<span class="text-ink-red-3">*</span>
</div>
<Input type="text" v-model="announcement.replyTo" />
</div>
@@ -70,8 +71,8 @@ const announcementResource = createResource({
url: 'frappe.core.doctype.communication.email.make',
makeParams(values) {
return {
recipients: props.students.join(', '),
cc: announcement.replyTo,
recipients: announcement.replyTo,
bcc: props.students.join(', '),
subject: announcement.subject,
content: announcement.announcement,
doctype: 'LMS Batch',
@@ -95,6 +96,9 @@ const makeAnnouncement = (close) => {
if (!announcement.announcement) {
return __('Announcement is required')
}
if (!announcement.replyTo) {
return __('Reply To is required')
}
},
onSuccess() {
close()

View File

@@ -99,6 +99,7 @@ const assessmentTypes = computed(() => {
return [
{ label: 'Quiz', value: 'LMS Quiz' },
{ label: 'Assignment', value: 'LMS Assignment' },
{ label: 'Programming Exercise', value: 'LMS Programming Exercise' },
]
})
</script>

View File

@@ -0,0 +1,230 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Course Progress Summary'),
size: '5xl',
}"
>
<template #body-content>
<div class="flex justify-between space-x-10 text-base mt-10">
<div class="w-full">
<div class="flex items-center justify-between space-x-5 mb-4">
<!-- <div class="text-xl font-semibold text-ink-gray-6">
{{ __('{0} Members').format(memberCount) }}
</div> -->
<FormControl
v-model="searchFilter"
:placeholder="__('Search by Member')"
type="text"
class="w-full"
/>
</div>
<div class="max-h-[70vh] overflow-y-auto">
<ListView
v-if="progressList.loading || progressList.data?.length"
:columns="progressColumns"
:rows="progressList.data"
rowKey="name"
:options="{
selectable: false,
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 progressColumns"
:key="item.key"
>
<template #prefix="{ item }">
<FeatherIcon
:name="item.icon?.toString()"
class="h-4 w-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in progressList.data">
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
</ListView>
<div
v-if="progressList.data && progressList.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="progressList.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
<div class="mb-4 self-start w-full space-y-5">
<div class="flex items-center space-x-4">
<NumberChart
class="border rounded-md w-full"
:config="{
title: __('Enrollments'),
value: memberCount || 0,
}"
/>
<NumberChart
class="border rounded-md w-full"
:config="{
title: __('Average Progress %'),
value: chartDetails.data?.average_progress || 0,
}"
/>
</div>
<DonutChart
:config="{
data: chartDetails.data?.progress_distribution || [],
title: __('Progress Distribution'),
categoryColumn: 'category',
valueColumn: 'count',
colors: [
theme.colors.red['400'],
theme.colors.amber['400'],
theme.colors.pink['400'],
theme.colors.blue['400'],
theme.colors.green['400'],
],
}"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
createResource,
Dialog,
DonutChart,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
NumberChart,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { theme } from '@/utils/theme'
const show = defineModel<boolean | undefined>()
const searchFilter = ref<string | null>(null)
type Filters = {
course: string | undefined
member_name?: string[]
}
const props = defineProps<{
courseName?: string
enrollments?: number
}>()
const memberCount = ref<number>(props.enrollments || 0)
const chartDetails = createResource({
url: 'lms.lms.api.get_course_progress_distribution',
params: {
course: props.courseName,
},
auto: true,
})
const progressList = createListResource({
doctype: 'LMS Enrollment',
filters: {
course: props.courseName,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'progress',
],
pageLength: 50,
auto: true,
})
watch([searchFilter], () => {
let filterApplied = false
let filters: Filters = {
course: props.courseName,
}
if (searchFilter.value) {
filters.member_name = ['like', `%${searchFilter.value}%`]
filterApplied = true
}
progressList.update({
filters: filters,
})
progressList.reload(
{},
{
onSuccess(data: any[]) {
memberCount.value = filterApplied ? data.length : props.enrollments || 0
},
}
)
})
const progressColumns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
width: '60%',
icon: 'user',
},
{
label: __('Progress'),
key: 'progress',
width: '30%',
align: 'right',
icon: 'trending-up',
},
]
})
</script>

View File

@@ -97,7 +97,7 @@ import {
} from 'frappe-ui'
import { reactive, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { getFileSize, escapeHTML } from '@/utils'
import { getFileSize } from '@/utils'
const reloadProfile = defineModel('reloadProfile')
@@ -132,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,

View File

@@ -139,16 +139,7 @@ function submitEvaluation(close) {
close()
},
onError(err) {
const message = err.messages?.[0] || err
let unavailabilityMessage
if (typeof message === 'string') {
unavailabilityMessage = message?.includes('unavailable')
} else {
unavailabilityMessage = false
}
toast.warning(__(unavailabilityMessage || 'Evaluator is unavailable'))
toast.warning(__(err.messages?.[0] || err))
},
})
}

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"
@@ -255,6 +255,9 @@ const saveEvaluation = () => {
}
toast.success(__('Evaluation saved successfully'))
},
onError(err) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
}
@@ -277,6 +280,9 @@ const certificateResource = createResource({
onSuccess(data) {
certificate.name = data
},
onError(err) {
toast.warning(__(err.messages?.[0] || err))
},
})
const certificateDetails = createResource({
@@ -310,6 +316,9 @@ const saveCertificate = () => {
onSuccess: () => {
toast.success(__('Certificate saved successfully'))
},
onError(err) {
toast.error(__(err.messages?.[0] || err))
},
}
)
}

View File

@@ -35,5 +35,6 @@ const file = computed(() => {
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
if (props.type == 'remove') return '/assets/lms/frontend/Remove.mp4'
})
</script>

View File

@@ -0,0 +1,106 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Attendance for Class - {0}').format(live_class?.title),
size: '4xl',
}"
>
<template #body-content>
<div
class="grid grid-cols-2 gap-12 text-sm font-semibold text-ink-gray-5 pb-2"
>
<div>
{{ __('Member') }}
</div>
<div class="grid grid-cols-3 gap-20">
<div>
{{ __('Joined at') }}
</div>
<div class="text-center">
{{ __('Left at') }}
</div>
<div>
{{ __('Attended for') }}
</div>
</div>
</div>
<div class="divide-y text-base">
<div
v-for="participant in participants.data"
@click="redirectToProfile(participant.member_username)"
class="grid grid-cols-2 items-center w-full text-base w-fit py-2"
>
<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>
<div class="grid grid-cols-3 gap-20 text-right">
<div>
{{ dayjs(participant.joined_at).format('HH:mm a') }}
</div>
<div>
{{ dayjs(participant.left_at).format('HH:mm a') }}
</div>
<div>{{ participant.duration }} {{ __('minutes') }}</div>
</div>
</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"
@@ -107,7 +103,11 @@ const dayjs = inject('$dayjs')
const props = defineProps({
batch: {
type: String,
default: null,
required: true,
},
zoomAccount: {
type: String,
required: true,
},
})
@@ -159,6 +159,7 @@ const createLiveClass = createResource({
return {
doctype: 'LMS Live Class',
batch_name: values.batch,
zoom_account: props.zoomAccount,
...values,
}
},
@@ -167,39 +168,11 @@ 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) {
@@ -208,6 +181,39 @@ const submitLiveClass = (close) => {
})
}
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) {
@@ -221,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

@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
size: '3xl',
size: '5xl',
}"
>
<template #body>
@@ -21,7 +21,7 @@
class="!p-0"
/>
</div>
<div v-if="!chooseFromExisting || editMode" class="space-y-2">
<div v-if="!chooseFromExisting || editMode">
<div>
<label class="block text-xs text-ink-gray-5 mb-1">
{{ __('Question') }}
@@ -34,7 +34,7 @@
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 class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-8 mt-4">
<FormControl
v-model="question.marks"
:label="__('Marks')"
@@ -51,7 +51,7 @@
</div>
<div
v-if="question.type == 'Choices'"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-10"
>
{{ __('Options') }}
</div>
@@ -61,7 +61,10 @@
>
{{ __('Possibilities') }}
</div>
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4">
<div
v-if="question.type == 'Choices'"
class="grid grid-cols-2 gap-x-8 gap-y-4"
>
<div v-for="n in 4" class="space-y-4 py-2">
<FormControl
:label="__('Option') + ' ' + n"
@@ -81,7 +84,7 @@
</div>
<div
v-else-if="question.type == 'User Input'"
class="grid grid-cols-2 gap-4 py-2"
class="grid grid-cols-2 gap-x-8 gap-y-4 py-2"
>
<div v-for="n in 4">
<FormControl
@@ -106,7 +109,7 @@
</div>
<div class="flex items-center justify-end space-x-2 mt-5">
<Button variant="solid" @click="submitQuestion()">
{{ __('Submit') }}
{{ __('Save') }}
</Button>
</div>
</div>
@@ -217,7 +220,7 @@ const questionRow = createResource({
return {
doc: {
doctype: 'LMS Quiz Question',
parent: quiz.value.data.name,
parent: quiz.value.doc.name,
parentfield: 'questions',
parenttype: 'LMS Quiz',
...values,

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

@@ -0,0 +1,252 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
title: __('Video Statistics for {0}').format(lessonTitle),
}"
>
<template #body-content>
<div class="text-base">
<div class="flex items-center justify-between">
<TabButtons
v-if="tabs.length > 1"
:buttons="tabs"
v-model="currentTab"
class="w-fit"
/>
<!-- <FormControl
v-model="searchText"
:placeholder="__('Search by Member')"
class="mt-2 mr-5 w-[25%]"
/> -->
</div>
<div v-if="currentTab" class="mt-4">
<div class="grid grid-cols-[55%,40%] gap-5">
<div
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto"
>
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
<div class="px-4">
{{ __('Member') }}
</div>
<div class="text-center">
{{ __('Watch Time') }}
</div>
</div>
<div
v-for="row in currentTabData"
class="hover:bg-surface-gray-1 cursor-pointer rounded-md py-1 px-2"
>
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
>
<div class="grid grid-cols-[70%,30%] items-center">
<div class="flex items-center space-x-2">
<Avatar
:image="row.member_image"
:label="row.member_name"
size="xl"
/>
<div class="space-y-1">
<div class="font-medium">
{{ row.member_name }}
</div>
<div class="text-sm text-ink-gray-6">
{{ row.member }}
</div>
</div>
</div>
<div class="text-center text-sm">
{{ formatTimestamp(row.watch_time) }}
</div>
</div>
</router-link>
</div>
</div>
<div class="space-y-5">
<NumberChart
class="border rounded-md"
:config="{
title: __('Average Watch Time'),
value: averageWatchTime,
}"
/>
<div v-if="isPlyrSource">
<div class="video-player" :src="currentTab"></div>
</div>
<VideoBlock v-else :file="currentTab" />
</div>
</div>
</div>
<div v-else class="text-sm text-ink-gray-5">
{{ __('No statistics available for this video.') }}
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
createListResource,
Dialog,
FormControl,
NumberChart,
TabButtons,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { enablePlyr, formatTimestamp } from '@/utils'
import VideoBlock from '@/components/VideoBlock.vue'
const show = defineModel<boolean | undefined>()
const currentTab = ref<string>('')
const searchText = ref<string>('')
type Filters = {
lesson: string | undefined
member_name?: string[]
}
const props = defineProps<{
lessonName?: string
lessonTitle?: string
}>()
const statistics = createListResource({
doctype: 'LMS Video Watch Duration',
filters: {
lesson: props.lessonName,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'source',
'watch_time',
],
cache: ['videoStatistics', props.lessonName],
onSuccess() {
currentTab.value = Object.keys(statisticsData.value)[0]
},
})
watch(
() => props.lessonName,
() => {
if (props.lessonName) {
statistics.filters.lesson = props.lessonName
statistics.reload()
}
}
)
watch(searchText, () => {
let filterApplied = false
let filters: Filters = {
lesson: props.lessonName,
}
if (searchText.value) {
filters.member_name = ['like', `%${searchText.value}%`]
filterApplied = true
}
statistics.update({
filters: filters,
})
statistics.reload({})
})
watch(show, () => {
if (show.value) {
enablePlyr()
}
})
const statisticsData = computed(() => {
const grouped = <Record<string, any[]>>{}
statistics.data.forEach((item: { source: string }) => {
if (!grouped[item.source]) {
grouped[item.source] = []
}
grouped[item.source].push(item)
})
return grouped
})
const averageWatchTime = computed(() => {
let totalWatchTime = 0
currentTabData.value.forEach((item: { watch_time: string }) => {
totalWatchTime += parseFloat(item.watch_time)
})
return formatTimestamp(totalWatchTime / currentTabData.value.length)
})
const currentTabData = computed(() => {
return statisticsData.value[currentTab.value] || []
})
const isPlyrSource = computed(() => {
return (
currentTab.value.includes('youtube') || currentTab.value.includes('vimeo')
)
})
const provider = computed(() => {
if (currentTab.value.includes('youtube')) {
return 'youtube'
} else if (currentTab.value.includes('vimeo')) {
return 'vimeo'
}
return ''
})
const embedURL = computed(() => {
if (isPlyrSource.value) {
return currentTab.value.replace('watch?v=', 'embed/')
}
return ''
})
const tabs = computed(() => {
return Object.keys(statisticsData.value).map((source, index) => ({
label: __(`Video ${index + 1}`),
value: source,
}))
})
</script>
<style>
.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

@@ -0,0 +1,213 @@
<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: string, close: () => void) => 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
}
setValue: {
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: () => void) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
updateAccount(close)
}
}
const createAccount = (close: () => void) => {
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: () => void) => {
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: () => void) => {
zoomAccounts.value?.setValue.submit(
{
...account,
name: account.name,
account_name: props.accountID,
},
{
onSuccess() {
zoomAccounts.value?.reload()
close()
toast.success(__('Zoom Account updated successfully'))
},
onError(err: any) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error updating Zoom Account')
)
},
}
)
}
</script>

View File

@@ -0,0 +1,241 @@
<template>
<div
class="text-sm absolute bg-white border rounded-md z-10 w-44"
:style="{
display: top > 0 ? 'block' : 'none',
top: top + 'px',
left: left + 'px',
}"
>
<div class="space-y-2 py-2">
<div class="text-xs text-ink-gray-5 font-medium px-3">
{{ __('Highlight') }}
</div>
<div class="">
<div
v-for="color in colors"
class="flex items-center space-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
@click="saveHighLight(color)"
>
<span
class="size-3 rounded-full"
:style="{
backgroundColor: theme.backgroundColor[color.toLowerCase()][400],
}"
></span>
<span>
{{ __(color) }}
</span>
</div>
</div>
</div>
<div class="border-t">
<div
@click="addToNotes()"
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
>
<NotepadText class="size-3 stroke-1.5" />
<span>
{{ __('Add to Notes') }}
</span>
</div>
<div
v-if="highlightExists()"
@click="deleteHighlight"
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
>
<Trash2 class="size-3 stroke-1.5" />
<span>
{{ __('Remove Highlight') }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { NotepadText, Trash2 } from 'lucide-vue-next'
import { theme } from '@/utils/theme'
import type { Note, Notes } from '@/components/Notes/types'
import { blockQuotesClick, highlightText } from '@/utils'
const user = inject<any>('$user')
const show = defineModel()
const notes = defineModel<Notes>('notes')
const top = ref(0)
const left = ref(0)
const currentSelection = ref<Selection | null>(null)
const selectedText = ref('')
const emit = defineEmits<{
(e: 'updateNotes'): void
}>()
const props = defineProps<{
lesson: string
}>()
watch(show, () => {
if (!show.value) {
return resetMenuPosition()
}
currentSelection.value = window.getSelection()
if (!currentSelection.value?.toString()) {
return resetMenuPosition()
}
updateMenuPosition()
})
const updateMenuPosition = () => {
selectedText.value = currentSelection.value?.toString() || ''
const range = currentSelection.value?.getRangeAt(0)
const rect = range?.getBoundingClientRect()
if (!rect) return
const offsetY = window.scrollY
const offsetX = window.scrollX
top.value = Math.floor(rect.top + offsetY - 40)
left.value = Math.floor(rect.right + offsetX + 10)
}
const resetMenuPosition = () => {
top.value = 0
left.value = 0
}
const colors = computed(() => {
return ['Red', 'Blue', 'Green', 'Yellow', 'Purple']
})
const highlightExists = () => {
return notes.value?.data?.some(
(note: Note) => note.highlighted_text === selectedText.value
)
}
const saveHighLight = (color: string) => {
if (!selectedText.value) return
notes.value?.insert.submit(
{
lesson: props.lesson,
member: user?.data?.name,
highlighted_text: selectedText.value,
color: color,
name: '',
},
{
onSuccess(data: Note) {
highlightText(data)
resetStates()
emit('updateNotes')
},
onError(err: any) {
console.error('Error saving highlight:', err)
resetStates()
},
}
)
}
const deleteHighlight = () => {
let notesToDelete = notes.value?.data.find(
(note: Note) => note.highlighted_text === selectedText.value
)
if (!notesToDelete) return
notes.value?.delete.submit(notesToDelete.name, {
onSuccess() {
resetStates()
document.querySelectorAll('.highlighted-text').forEach((el) => {
const element = el as HTMLElement
if (element.dataset.name === notesToDelete.name) {
element.style.backgroundColor = 'transparent'
}
})
},
onError(err: any) {
console.error('Error deleting highlight:', err)
resetStates()
},
})
}
const addToNotes = () => {
if (!selectedText.value) return
let noteToUpdate = notes.value?.data.find((note: Note) => {
return !note.highlighted_text && note.note !== ''
})
if (!noteToUpdate) {
createNote()
} else {
updateNote(noteToUpdate)
}
}
const createNote = () => {
notes.value?.insert.submit(
{
lesson: props.lesson,
member: user?.data?.name,
note: `<blockquote><p>${selectedText.value}</p></blockquote><br>`,
color: 'Yellow',
name: '',
},
{
onSuccess(data: Note) {
emit('updateNotes')
setTimeout(() => {
scrollToText(selectedText.value)
blockQuotesClick()
resetStates()
}, 100)
},
onError(err: any) {
console.error('Error creating note:', err)
resetStates()
},
}
)
}
const updateNote = (noteToUpdate: Note) => {
notes.value?.setValue.submit(
{
name: noteToUpdate.name,
note: `${noteToUpdate.note}\n\n<blockquote><p>${selectedText.value}</p></blockquote><br>`,
},
{
onSuccess(data: Note) {
emit('updateNotes')
setTimeout(() => {
scrollToText(selectedText.value)
blockQuotesClick()
resetStates()
}, 100)
},
onError(err: any) {
console.error('Error updating note:', err)
resetStates()
},
}
)
}
const scrollToText = (text: string) => {
const elements = document.querySelectorAll('blockquote p')
Array.from(elements).forEach((el) => {
const element = el as HTMLElement
if (element.textContent?.toLowerCase().includes(text.toLowerCase())) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
}
const resetStates = () => {
selectedText.value = ''
show.value = false
resetMenuPosition()
}
</script>

View File

@@ -0,0 +1,115 @@
<template>
<div class="text-lg font-semibold mb-4">
{{ __('My Notes') }}
</div>
<TextEditor
:content="note"
:placeholder="__('Make notes for quick revision. Press / for menu.')"
@change="(val: string) => updateNoteText(val)"
:editable="true"
editorClass="prose prose-sm min-h-[200px] max-w-none"
/>
</template>
<script setup lang="ts">
import { TextEditor } from 'frappe-ui'
import { useDebounceFn } from '@vueuse/core'
import { inject, ref, onMounted, watch } from 'vue'
import type { Note, Notes } from '@/components/Notes/types'
import { blockQuotesClick } from '@/utils/'
const note = ref<string | null>(null)
const currentNoteName = ref<string | null>(null)
const user = inject<any>('$user')
const notes = defineModel<Notes>('notes')
const emit = defineEmits<{
(e: 'updateNotes'): void
}>()
const props = defineProps<{
lesson: string
}>()
onMounted(() => {
updateCurrentNote()
})
watch(
() => notes.value?.data,
() => {
updateCurrentNote()
blockQuotesClick()
}
)
const updateCurrentNote = () => {
const currentNote = notes.value?.data?.filter((row: Note) => {
return !row.highlighted_text && row.note !== ''
})
if (currentNote?.length === 0) {
note.value = null
currentNoteName.value = null
return
} else if (currentNote && currentNote.length > 0) {
currentNoteName.value = currentNote[0].name
note.value = currentNote[0].note || null
}
}
const updateNoteText = (val: string) => {
note.value = val
debouncedSave()
}
const debouncedSave = useDebounceFn(() => {
saveNotes()
}, 2000)
const saveNotes = () => {
if (currentNoteName.value) {
updateNote()
} else {
createNote()
}
}
const createNote = () => {
notes.value?.insert.submit(
{
lesson: props.lesson,
member: user?.data?.name,
note: note.value,
color: 'Yellow',
name: '',
},
{
onSuccess(data: Note) {
currentNoteName.value = data.name || null
emit('updateNotes')
},
onError(err: any) {
console.error('Error creating note:', err)
},
}
)
}
const updateNote = () => {
if (!currentNoteName.value) return
notes.value?.setValue.submit(
{
name: currentNoteName.value,
lesson: props.lesson,
member: user?.data?.name,
note: note.value,
},
{
onSuccess(data: Note) {
emit('updateNotes')
},
onError(err: any) {
console.error('Error updating note:', err)
},
}
)
}
</script>

View File

@@ -0,0 +1,32 @@
export type Note = {
highlighted_text?: string
color?: string
name: string
note?: string | null
lesson?: string
member?: string
}
export type Notes = {
data: Note[]
reload: () => void
insert: {
submit: (
data: Note,
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
) => void
}
setValue: {
submit: (
data: Note,
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
) => void
},
delete: {
submit: (
data: Note | string,
options?: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}

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-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 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)
@@ -38,6 +41,16 @@
)
}}
</div>
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
{{
__(
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
).format(
quiz.data.marks_to_cut,
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
)
}}
</div>
</div>
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
@@ -55,19 +68,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 +271,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="
@@ -308,13 +337,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({
@@ -611,11 +647,17 @@ const getInstructions = (question) => {
}
const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') {
let pathname = window.location.pathname.split('/')
if (!pathname.includes('courses'))
pathname = window.parent.location.pathname.split('/')
if (pathname[2] != 'courses') return
let lessonIndex = pathname.pop().split('-')
if (lessonIndex.length == 2) {
call('lms.lms.api.mark_lesson_progress', {
course: router.currentRoute.value.params.courseName,
chapter_number: router.currentRoute.value.params.chapterNumber,
lesson_number: router.currentRoute.value.params.lessonNumber,
course: pathname[3],
chapter_number: lessonIndex[0],
lesson_number: lessonIndex[1],
})
}
}

View File

@@ -0,0 +1,52 @@
<template>
<div v-if="relatedCourses.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-6">
<div class="text-2xl font-semibold text-ink-gray-9">
{{ __('Related Courses') }}
</div>
</div>
<div
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 relatedCourses.data"
:key="course.name"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
class="cursor-pointer"
>
<CourseCard :course="course" />
</router-link>
</div>
</div>
</template>
<script setup>
import { createResource } from 'frappe-ui'
import { watch } from 'vue'
import CourseCard from '@/components/CourseCard.vue'
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
const relatedCourses = createResource({
url: 'lms.lms.utils.get_related_courses',
cache: ['related_courses', props.courseName],
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
relatedCourses.reload()
}
)
</script>

View File

@@ -0,0 +1,142 @@
<template>
<Dialog
v-model="show"
:options="{
title:
props.badgeAssignmentID === 'new'
? __('Assign a Badge')
: __('Edit Badge Assignment'),
size: 'sm',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveBadgeAssignment(close)
},
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<Link
doctype="User"
v-model="badgeAssignment.member"
:label="__('Member')"
:required="true"
/>
<Link
doctype="LMS Badge"
v-model="badgeAssignment.badge"
:label="__('Badge')"
:required="true"
/>
<div>
<label class="text-xs text-ink-gray-5 mb-1">
{{ __('Issued On') }}
<span class="text-ink-red-3">*</span>
</label>
<DatePicker
v-model="badgeAssignment.issued_on"
:placeholder="__('Select Date')"
:required="true"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Dialog, DatePicker, toast } from 'frappe-ui'
import type {
BadgeAssignments,
BadgeAssignment,
} from '@/components/Settings/types'
import { ref, watch } from 'vue'
import { cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const show = defineModel<boolean>({ required: true, default: false })
const defaultBadgeAssignment = {
name: '',
badge: '',
member: '',
issued_on: '',
member_name: '',
member_username: '',
member_image: '',
}
const badgeAssignments = defineModel<BadgeAssignments>('badgeAssignments')
const badgeAssignment = ref<BadgeAssignment>(defaultBadgeAssignment)
const props = defineProps<{
badgeAssignmentID: string
badge: string | null
}>()
watch(
() => props.badgeAssignmentID,
(newID) => {
if (newID === 'new') {
badgeAssignment.value = {
...defaultBadgeAssignment,
badge: props.badge || '',
}
} else {
const assignment = badgeAssignments.value?.data?.find(
(assignment) => assignment.name === newID
)
if (assignment) {
badgeAssignment.value = { ...assignment }
}
}
}
)
const saveBadgeAssignment = (close: () => void) => {
if (props.badgeAssignmentID === 'new') {
createBadgeAssignment(close)
} else {
updateBadgeAssignment(close)
}
}
const updateBadgeAssignment = async (close: () => void) => {
badgeAssignments.value?.setValue.submit(
{
...badgeAssignment.value,
},
{
onSuccess: () => {
toast.success(__('Badge assignment updated successfully'))
close()
},
onError: (error) => {
toast.error(
__('Failed to update badge assignment: ') + cleanError(error)
)
},
}
)
}
const createBadgeAssignment = (close: () => void) => {
badgeAssignments.value?.insert.submit(
{
...badgeAssignment.value,
},
{
onSuccess: () => {
toast.success(__('Badge assignment created successfully'))
close()
},
onError: (error) => {
toast.error(
__('Failed to create badge assignment: ') + cleanError(error)
)
},
}
)
}
</script>

View File

@@ -0,0 +1,192 @@
<template>
<div class="text-base">
<div class="flex items-center justify-between space-x-2 mb-5">
<div class="flex items-center space-x-2">
<ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-5 cursor-pointer"
@click="
() => {
show = false
}
"
/>
<div class="text-xl font-semibold text-ink-gray-9">
{{ props.badgeName }}
</div>
</div>
<Button @click="openForm('new')">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
<div v-if="assignments.data?.length">
<ListView
:rows="assignments.data"
:columns="columns"
rowKey="name"
:options="{
showTooltip: false,
onRowClick: (row: BadgeAssignment) => {
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 }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in assignments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<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="deleteBadgeAssignment(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="flex flex-col items-center justify-center mt-44">
<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 Assignments') }}
</div>
<div
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
>
{{ __('This badge has not been assigned to any students yet') }}
</div>
</div>
</div>
<BadgeAssignmentForm
v-model="showForm"
:badgeAssignmentID="currentAssignmentID"
:badge="props.badgeName"
v-model:badgeAssignments="assignments"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { ChevronLeft, GraduationCap, Plus, Trash2 } from 'lucide-vue-next'
import { computed, inject, ref } from 'vue'
import type { BadgeAssignment } from '@/components/Settings/types'
import BadgeAssignmentForm from '@/components/Settings/BadgeAssignmentForm.vue'
const show = defineModel<boolean>()
const dayjs = inject('$dayjs') as any
const showForm = ref(false)
const currentAssignmentID = ref<string>('')
const props = defineProps<{
badgeName: string | null
}>()
const assignments = createListResource({
doctype: 'LMS Badge Assignment',
fields: [
'name',
'member',
'member_name',
'member_username',
'member_image',
'issued_on',
'badge',
],
filters: {
badge: props.badgeName,
},
order_by: 'issued_on desc',
transform(data: BadgeAssignment[]) {
return data.map((item: BadgeAssignment) => {
return {
...item,
issued_on: item.issued_on
? dayjs(item.issued_on).format('DD MMM YYYY')
: null,
}
})
},
auto: true,
})
const openForm = (assignmentID: string) => {
currentAssignmentID.value = assignmentID
showForm.value = true
}
const deleteBadgeAssignment = (
selections: Set<string>,
unselectAll: () => void
) => {
Array.from(selections).forEach(async (assignment: string) => {
await assignments.delete.submit(assignment)
})
unselectAll()
toast.success(__('Badge assignments deleted successfully'))
}
const columns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
icon: 'user',
width: '60%',
},
{
label: __('Issued On'),
key: 'issued_on',
icon: 'calendar',
align: 'center',
},
]
})
</script>

View File

@@ -0,0 +1,219 @@
<template>
<Dialog
v-model="show"
:options="{
title: badge ? __('Edit Badge') : __('Create a new Badge'),
size: '3xl',
}"
>
<template #body-content>
<div class="grid grid-cols-2 gap-x-5">
<div class="space-y-4">
<FormControl
v-model="badge.enabled"
:label="__('Enabled')"
type="checkbox"
/>
<FormControl
v-model="badge.title"
:label="__('Title')"
type="text"
:required="true"
/>
<Autocomplete
@update:modelValue="(opt: any) => (badge.reference_doctype = opt.value)"
:modelValue="badge.reference_doctype"
:options="referenceDoctypeOptions"
:required="true"
:label="__('Assign For')"
/>
<FormControl
v-model="badge.description"
:label="__('Description')"
:required="true"
type="textarea"
/>
<Uploader
v-model="badge.image"
label="Badge Image"
description="An image that represents the badge."
/>
</div>
<div class="space-y-4">
<FormControl
v-model="badge.grant_only_once"
:label="__('Grant Only Once')"
type="checkbox"
/>
<FormControl
v-model="badge.event"
:label="__('Event')"
type="select"
:options="eventOptions"
:required="true"
/>
<FormControl
v-model="badge.user_field"
:label="__('Assign To')"
type="select"
:options="userFieldOptions"
:required="true"
/>
<CodeEditor
v-model="badge.condition"
:label="__('Condition')"
type="JavaScript"
:required="true"
:showBorder="true"
height="82px"
/>
</div>
<div class="space-y-4"></div>
</div>
</template>
<template #actions="{ close }">
<div class="pb-5 float-right">
<Button variant="solid" @click="saveBadge(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { cleanError } from '@/utils'
import type { Badges, Badge } from '@/components/Settings/types'
import Autocomplete from '@/components/Controls/Autocomplete.vue'
import CodeEditor from '@/components/Controls/CodeEditor.vue'
import Uploader from '@/components/Controls/Uploader.vue'
const defaultBadge = {
name: '',
title: '',
enabled: true,
description: '',
image: '',
grant_only_once: false,
event: 'New',
reference_doctype: '',
condition: '',
user_field: 'member',
field_to_check: '',
}
const show = defineModel<boolean>({ required: true, default: false })
const badges = defineModel<Badges>('badges')
const badge = ref<Badge>(defaultBadge)
const props = defineProps<{
badgeName: string | null
}>()
watch(
() => props.badgeName,
(val) => {
if (val != 'new') {
badges.value?.data.forEach((bdg: Badge) => {
if (bdg.name === val) {
badge.value = bdg
}
})
} else {
badge.value = { ...defaultBadge }
}
}
)
const saveBadge = (close: () => void) => {
if (props.badgeName == 'new') {
createBadge(close)
} else {
updateBadge(close)
}
}
const updateBadge = async (close: () => void) => {
if (props.badgeName != badge.value?.title) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Badge',
old_name: props.badgeName,
new_name: badge.value?.title,
})
}
const setValue = (close: () => void) => {
badges.value?.setValue.submit(
{
...badge.value,
name: badge.value.title,
},
{
onSuccess() {
badges.value?.reload()
close()
toast.success(__('Badge updated successfully'))
},
onError(err: any) {
close()
toast.error(cleanError(err.messages[0]) || err)
},
}
)
}
const createBadge = (close: () => void) => {
badges.value?.insert.submit(
{
...badge.value,
name: badge.value.name,
},
{
onSuccess() {
badges.value?.reload()
close()
toast.success(__('Badge created successfully'))
},
onError(err) {
close()
toast.error(cleanError(err.messages[0]) || __('Error creating badge'))
},
}
)
}
const referenceDoctypeOptions = computed(() => {
return [
{ label: __('Course'), value: 'LMS Course' },
{ label: __('Batch'), value: 'LMS Batch' },
{ label: __('User'), value: 'Member' },
{ label: __('Quiz Submission'), value: 'LMS Quiz Submission' },
{ label: __('Assignment Submission'), value: 'LMS Assignment Submission' },
{
label: __('Programming Exercise Submission'),
value: 'LMS Programming Exercise Submission',
},
{ label: __('Course Enrollment'), value: 'LMS Enrollment' },
{ label: __('Batch Enrollment'), value: 'LMS Batch Enrollment' },
]
})
const eventOptions = computed(() => {
let options = ['New', 'Value Change', 'Auto Assign']
return options.map((event) => ({ label: __(event), value: event }))
})
const userFieldOptions = computed(() => {
return [
{ label: __('Member'), value: 'member' },
{ label: __('Owner'), value: 'owner' },
]
})
</script>

View File

@@ -0,0 +1,247 @@
<template>
<BadgeAssignments
v-if="showAssignments"
v-model="showAssignments"
:badgeName="showAssignmentsFor"
/>
<div v-else 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-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
<div v-if="badges.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="badges.data"
row-key="name"
:options="{
showTooltip: false,
selectable: 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" :key="item.key">
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in badges.data" :key="row.name">
<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="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else-if="column.key == 'reference_doctype'">
{{
doctypeLabel[
row[column.key] as keyof typeof doctypeLabel
] || row[column.key]
}}
</div>
<FormControl
v-else-if="column.key == 'grant_only_once'"
v-model="row[column.key]"
type="checkbox"
:disabled="true"
/>
<div
v-else-if="column.key != 'action'"
class="leading-5 text-sm"
>
{{ row[column.key] }}
</div>
<Dropdown
v-else
:options="getMoreOptions(row.name)"
:button="{
icon: 'more-horizontal',
onblur: (e: Event) => {
e.stopPropagation()
},
}"
placement="right"
/>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
</div>
<BadgeForm
v-model="showForm"
:badgeName="selectedBadge"
v-model:badges="badges"
/>
</template>
<script setup lang="ts">
import {
Badge,
Button,
createListResource,
Dropdown,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import BadgeForm from '@/components/Settings/BadgeForm.vue'
import BadgeAssignments from '@/components/Settings/BadgeAssignments.vue'
const showForm = ref<boolean>(false)
const selectedBadge = ref<string | null>(null)
const showAssignments = ref<boolean>(false)
const showAssignmentsFor = ref<string | null>(null)
const props = defineProps<{
label: string
description: string
}>()
const badges = createListResource({
doctype: 'LMS Badge',
fields: [
'name',
'title',
'enabled',
'description',
'image',
'grant_only_once',
'event',
'reference_doctype',
'condition',
'user_field',
'field_to_check',
],
order_by: 'creation desc',
auto: true,
})
const getMoreOptions = (badgeName: string) => {
return [
{
label: __('Edit'),
icon: 'edit',
onClick() {
openForm(badgeName)
},
},
{
label: __('Assignments'),
icon: 'download',
onClick() {
showAssignmentsFor.value = badgeName
showAssignments.value = true
},
},
{
label: __('Delete'),
icon: 'trash-2',
onClick() {
deleteBadge(badgeName)
},
},
]
}
const openForm = (badgeName: string) => {
selectedBadge.value = badgeName
showForm.value = true
}
const deleteBadge = (badgeName: string) => {
badges.delete
.submit(badgeName)
.then(() => {
badges.reload()
toast.success(__('Badge deleted successfully'))
})
.catch((err: any) => {
toast.error(cleanError(err.messages[0]) || __('Error deleting badge'))
})
}
const doctypeLabel = computed(() => {
return {
'LMS Course': __('Course'),
'LMS Batch': __('Batch'),
'LMS Enrollment': __('Course Enrollment'),
'LMS Batch Enrollment': __('Batch Enrollment'),
'LMS Quiz Submission': __('Quiz Submission'),
'LMS Assignment Submission': __('Assignment Submission'),
'LMS Programming Exercise Submission': __(
'Programming Exercise Submission'
),
}
})
const columns = computed(() => {
return [
{
label: __('Badge'),
key: 'title',
icon: 'award',
align: 'left',
width: '25%',
},
{
label: __('Assigned For'),
key: 'reference_doctype',
icon: 'info',
align: 'left',
width: '35%',
},
{
label: __('Status'),
key: 'enabled',
icon: 'check-square',
align: 'left',
width: '15%',
},
{
label: __('Grant Only Once'),
key: 'grant_only_once',
icon: 'check',
align: 'center',
width: '20%',
},
{
key: 'action',
align: 'right',
},
]
})
</script>

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">
@@ -17,7 +17,7 @@
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :fields="fields" :data="data.data" />
<SettingFields :fields="fields" :data="branding.data" />
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
@@ -28,7 +28,7 @@
</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)
@@ -38,10 +38,6 @@ const props = defineProps({
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
label: {
type: String,
required: true,
@@ -51,6 +47,12 @@ const props = defineProps({
},
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
@@ -64,7 +66,7 @@ const saveSettings = createResource({
const update = () => {
let fieldsToSave = {}
let imageFields = ['favicon', 'banner_image', 'footer_logo']
let imageFields = ['favicon', 'banner_image']
props.fields.forEach((f) => {
if (imageFields.includes(f.name)) {
fieldsToSave[f.name] = f.value ? f.value.file_url : null
@@ -72,6 +74,8 @@ const update = () => {
fieldsToSave[f.name] = f.value
}
})
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
saveSettings.submit(
{
fields: fieldsToSave,
@@ -84,9 +88,31 @@ const update = () => {
)
}
watch(props.data, (newData) => {
if (newData && !isDirty.value) {
isDirty.value = true
}
watch(branding, (updatedDoc) => {
let textFields = []
let imageFields = []
props.fields.forEach((f) => {
if (f.type === 'Upload') {
imageFields.push(f.name)
} else {
textFields.push(f.name)
}
})
textFields.forEach((field) => {
if (updatedDoc.data[field] != updatedDoc.previousData[field]) {
isDirty.value = true
}
})
imageFields.forEach((field) => {
if (
updatedDoc.data[field] &&
updatedDoc.data[field].file_url != updatedDoc.previousData[field].file_url
) {
isDirty.value = true
}
})
})
</script>

View File

@@ -5,9 +5,9 @@
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-xs text-ink-gray-5">
<!-- <div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div>
</div> -->
</div>
<div class="flex items-center space-x-5">
<Button @click="openTemplateForm('new')">

View File

@@ -0,0 +1,204 @@
<template>
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="flex item-center space-x-2">
<Button variant="solid" @click="() => (showForm = !showForm)">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div class="mt-8 pb-5">
<FormControl
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
class="w-1/4 mb-4"
>
<template #prefix>
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-auto h-[60vh]">
<div class="divide-y">
<div
v-for="evaluator in evaluators.data"
:key="evaluator.evaluator"
class="cursor-pointer"
>
<div class="flex items-center justify-between group py-3">
<div
class="flex items-center space-x-3"
@click="openProfile(evaluator.username)"
>
<Avatar
:image="evaluator.user_image"
:label="evaluator.full_name"
size="xl"
/>
<div class="space-y-1">
<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 class="invisible group-hover:visible">
<Button
variant="ghost"
@click="deleteEvaluator(evaluator.evaluator)"
>
<template #icon>
<Trash2 class="size-4 stroke-1.5 text-ink-red-3" />
</template>
</Button>
</div>
</div>
</div>
</div>
<div
v-if="evaluators.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="evaluators.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
</div>
<Dialog
v-model="showForm"
:options="{
size: 'xl',
title: __('Add Evaluator'),
actions: [{
label: __('Add'),
variant: 'solid',
onClick({ close }: any) {
addEvaluator(close)
},
}]
}"
>
<template #body-content>
<div v-if="showForm" class="flex items-center">
<FormControl
v-model="email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
class="w-full"
@keydown.enter="addEvaluator"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
call,
createListResource,
Dialog,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch } from 'vue'
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
const show = defineModel('show')
const search = ref('')
const showForm = ref(false)
const email = ref('')
const router = useRouter()
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
show: {
type: Boolean,
},
})
const evaluators = createListResource({
doctype: 'Course Evaluator',
fields: ['evaluator', 'username', 'full_name', 'user_image'],
auto: true,
orderBy: 'creation desc',
})
const addEvaluator = (close: () => void) => {
call('lms.lms.api.add_an_evaluator', {
email: email.value,
})
.then(() => {
email.value = ''
evaluators.reload()
toast.success(__('Evaluator added successfully'))
close()
})
.catch((error: any) => {
toast.error(__(error.messages[0] || error.messages))
console.error('Error adding evaluator:', error)
})
}
watch(search, () => {
evaluators.update({
filters: {
full_name: ['like', `%${search.value}%`],
},
})
evaluators.reload()
})
const openProfile = (username: string) => {
show.value = false
router.push({
name: 'Profile',
params: {
username: username,
},
})
}
const deleteEvaluator = (evaluator: string) => {
call('lms.lms.api.delete_evaluator', {
evaluator: evaluator,
})
.then(() => {
toast.success(__('Evaluator deleted successfully'))
evaluators.reload()
})
.catch((error: any) => {
toast.error(__(error.messages[0] || error.messages))
console.error('Error deleting evaluator:', error)
})
}
</script>

View File

@@ -5,53 +5,37 @@
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div> -->
</div>
</div>
<div class="flex item-center space-x-2">
<FormControl
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
/>
<Button @click="() => (showForm = !showForm)">
<Button variant="solid" @click="() => (showForm = !showForm)">
<template #prefix>
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
<X v-else class="size-4 stroke-1.5" />
<Plus class="size-4 stroke-1.5" />
</template>
{{ showForm ? __('Close') : __('New') }}
{{ __('New') }}
</Button>
</div>
</div>
<!-- Form to add new member -->
<div v-if="showForm" class="flex items-center space-x-2 my-4">
<div class="mt-8 pb-10">
<FormControl
v-model="member.email"
:placeholder="__('Email')"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:placeholder="__('First Name')"
v-model="search"
:placeholder="__('Search')"
type="text"
class="w-full"
/>
<Button @click="addMember()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="mt-2 pb-10 overflow-auto">
<!-- Member list -->
<div class="overflow-y-scroll">
:debounce="300"
class="w-1/4 mb-4"
>
<template #prefix>
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-y-scroll h-[60vh]">
<ul class="divide-y">
<li
v-for="member in memberList"
class="grid grid-cols-3 gap-10 py-2 cursor-pointer"
class="flex items-center justify-between py-2 cursor-pointer"
>
<div
@click="openProfile(member.username)"
@@ -60,27 +44,13 @@
<Avatar
:image="member.user_image"
:label="member.full_name"
size="lg"
size="xl"
/>
<div class="space-y-1">
<div class="flex">
<div class="text-ink-gray-9">
{{ member.full_name }}
</div>
<div
class="px-1"
v-if="member.role && getRole(member.role) !== 'Student'"
>
<Badge
:variant="'subtle'"
:ref_for="true"
theme="blue"
size="sm"
label="Badge"
>
{{ getRole(member.role) }}
</Badge>
</div>
</div>
<div class="text-sm text-ink-gray-7">
{{ member.name }}
@@ -88,59 +58,92 @@
</div>
</div>
<div
class="flex items-center justify-center text-ink-gray-7 text-sm"
class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
v-if="member.role && member.role !== 'LMS Student'"
>
<div v-if="member.last_active">
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
</div>
<div v-else>-</div>
<Shield class="size-4 stroke-1.5" />
<span class="text-sm">
{{ getRole(member.role) }}
</span>
</div>
</li>
</ul>
</div>
<div
v-if="memberList.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="members.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
<div
v-if="memberList.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="members.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
</div>
<Dialog
v-model="showForm"
:options="{
title: __('Add a new member'),
size: 'lg',
actions: [{
label: __('Add'),
variant: 'solid',
onClick({ close }: any) {
addMember(close)
}
}]
}"
>
<template #body-content>
<div class="flex items-center space-x-2">
<FormControl
v-model="member.email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:label="__('First Name')"
placeholder="Jane"
type="text"
class="w-full"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { createResource, Avatar, Button, FormControl, Badge } from 'frappe-ui'
import {
Avatar,
Badge,
Button,
createResource,
Dialog,
FormControl,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, X } from 'lucide-vue-next'
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe'
import type { User } from '@/components/Settings/types'
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
}
type Member = {
username: string
full_name: string
name: string
role?: string
user_image?: string
}
const router = useRouter()
const show = defineModel('show')
const search = ref('')
const start = ref(0)
const memberList = ref([])
const memberList = ref<Member[]>([])
const hasNextPage = ref(false)
const showForm = ref(false)
const dayjs = inject('$dayjs')
@@ -174,7 +177,7 @@ const members = createResource({
start: start.value,
}
},
onSuccess(data) {
onSuccess(data: Member[]) {
memberList.value = memberList.value.concat(data)
start.value = start.value + 20
hasNextPage.value = data.length === 20
@@ -182,7 +185,7 @@ const members = createResource({
auto: true,
})
const openProfile = (username) => {
const openProfile = (username: string) => {
show.value = false
router.push({
name: 'Profile',
@@ -194,7 +197,7 @@ const openProfile = (username) => {
const newMember = createResource({
url: 'frappe.client.insert',
makeParams(values) {
makeParams() {
return {
doc: {
doctype: 'User',
@@ -204,13 +207,12 @@ const newMember = createResource({
}
},
auto: false,
onSuccess(data) {
onSuccess(data: Member) {
show.value = false
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
router.push({
name: 'Profile',
name: 'ProfileRoles',
params: {
username: data.username,
},
@@ -218,8 +220,9 @@ const newMember = createResource({
},
})
const addMember = () => {
const addMember = (close: () => void) => {
newMember.reload()
close()
}
watch(search, () => {
@@ -228,8 +231,8 @@ watch(search, () => {
members.reload()
})
const getRole = (role) => {
const map = {
const getRole = (role: string) => {
const map: Record<string, string> = {
'LMS Student': 'Student',
'Course Creator': 'Instructor',
Moderator: 'Moderator',

View File

@@ -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: {

View File

@@ -28,7 +28,7 @@
<script setup>
import { Button, Badge, toast } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue'
import SettingFields from '@/components/Settings/SettingFields.vue'
const props = defineProps({
fields: {

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-[21rem]' : 'w-1/2'"
:class="columns.length > 1 ? 'w-[21rem]' : 'w-full'"
>
<div v-for="field in column">
<Link
@@ -55,11 +55,13 @@
<div v-else>
<div class="flex items-center text-sm space-x-2">
<div
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2 px-20 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 || data[field.name]"
class="size-6 rounded"
class="rounded"
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
/>
</div>
<div class="flex flex-col flex-wrap">
@@ -101,6 +103,7 @@
:rows="field.rows"
:options="field.options"
:description="field.description"
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
/>
</div>
</div>

View File

@@ -34,27 +34,16 @@
:key="activeTab.label"
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
>
<Members
v-if="activeTab.label === 'Members'"
:label="activeTab.label"
:description="activeTab.description"
v-model:show="show"
/>
<Evaluators
v-else-if="activeTab.label === 'Evaluators'"
:label="activeTab.label"
:description="activeTab.description"
v-model:show="show"
/>
<Categories
v-else-if="activeTab.label === 'Categories'"
:label="activeTab.label"
:description="activeTab.description"
/>
<EmailTemplates
v-else-if="activeTab.label === 'Email Templates'"
:label="activeTab.label"
:description="activeTab.description"
<component
v-if="activeTab.template"
:is="activeTab.template"
v-bind="{
label: activeTab.label,
description: activeTab.description,
...(activeTab.label === 'Branding'
? { fields: activeTab.fields }
: {}),
}"
/>
<PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'"
@@ -63,13 +52,6 @@
:data="data"
:fields="activeTab.fields"
/>
<BrandSettings
v-else-if="activeTab.label === 'Branding'"
:label="activeTab.label"
:description="activeTab.description"
:fields="activeTab.fields"
:data="branding"
/>
<SettingDetails
v-else
:fields="activeTab.fields"
@@ -83,17 +65,19 @@
</Dialog>
</template>
<script setup>
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue'
import { Dialog, createDocumentResource } from 'frappe-ui'
import { computed, markRaw, ref, 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 EmailTemplates from '@/components/EmailTemplates.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'
import Badges from '@/components/Settings/Badges.vue'
const show = defineModel()
const doctype = ref('LMS Settings')
@@ -108,12 +92,6 @@ const data = createDocumentResource({
auto: true,
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const tabsStructure = computed(() => {
return [
{
@@ -124,6 +102,13 @@ const tabsStructure = computed(() => {
label: 'General',
icon: 'Wrench',
fields: [
{
label: 'Allow Guest Access',
name: 'allow_guest_access',
description:
'If enabled, users can access the course and batch lists without logging in.',
type: 'checkbox',
},
{
label: 'Enable Learning Paths',
name: 'enable_learning_paths',
@@ -132,11 +117,11 @@ const tabsStructure = computed(() => {
type: 'checkbox',
},
{
label: 'Allow Guest Access',
name: 'allow_guest_access',
description:
'If enabled, users can access the course and batch lists without logging in.',
label: 'Prevent Skipping Videos',
name: 'prevent_skipping_videos',
type: 'checkbox',
description:
'If enabled, users will no able to move forward in a video',
},
{
label: 'Send calendar invite for evaluations',
@@ -149,13 +134,21 @@ const tabsStructure = computed(() => {
type: 'Column Break',
},
{
label: 'Batch Confirmation Template',
label: 'Livecode URL',
name: 'livecode_url',
doctype: 'Livecode URL',
type: 'text',
description:
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
},
{
label: 'Batch Confirmation Email Template',
name: 'batch_confirmation_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Certification Template',
label: 'Certification Email Template',
name: 'certification_template',
doctype: 'Email Template',
type: 'Link',
@@ -221,33 +214,55 @@ const tabsStructure = computed(() => {
items: [
{
label: 'Members',
description: 'Manage the members of your learning system',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: 'Manage the evaluators of your learning system',
description: '',
icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
template: markRaw(Evaluators),
},
{
label: 'Zoom Accounts',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Badges',
description:
'Create badges and assign them to students to acknowledge their achievements',
icon: 'Award',
template: markRaw(Badges),
},
{
label: 'Categories',
description: 'Double click to edit the category',
icon: 'Network',
template: markRaw(Categories),
},
{
label: 'Email Templates',
description: 'Manage the email templates for your learning system',
icon: 'MailPlus',
template: markRaw(EmailTemplates),
},
],
},
{
label: 'Customise',
label: 'Customize',
hideLabel: false,
items: [
{
label: 'Branding',
icon: 'Blocks',
template: markRaw(BrandSettings),
fields: [
{
label: 'Brand Name',
@@ -282,8 +297,13 @@ const tabsStructure = computed(() => {
type: 'checkbox',
},
{
label: 'Certified Participants',
name: 'certified_participants',
label: 'Programming Exercises',
name: 'programming_exercises',
type: 'checkbox',
},
{
label: 'Certified Members',
name: 'certified_members',
type: 'checkbox',
},
{
@@ -324,6 +344,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',
@@ -351,12 +374,16 @@ const tabsStructure = computed(() => {
type: 'textarea',
rows: 4,
description:
'Keywords for search engines to find your website. Separated by commas.',
'Comma separated keywords for search engines to find your website.',
},
{
type: 'Column Break',
},
{
label: 'Meta Image',
name: 'meta_image',
type: 'Upload',
size: 'lg',
},
],
},

View File

@@ -0,0 +1,204 @@
<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-ink-gray-6 leading-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 }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</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">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('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 {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
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',
'member_image',
'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: __('Member'),
key: 'member_name',
icon: 'user',
},
{
label: __('Account Name'),
key: 'name',
icon: 'video',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
icon: 'check-square',
},
]
})
</script>

View File

@@ -0,0 +1,74 @@
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
}
}
export interface Badge {
name: string;
title: string;
enabled: boolean;
description: string;
image: string;
grant_only_once: boolean;
event: string;
reference_doctype: string;
condition: string;
user_field: string;
field_to_check: string;
};
export interface Badges {
data: Badge[],
reload: () => void
insert: {
submit: (
data: Badge,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
setValue: {
submit: (
data: Badge,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
}
export interface BadgeAssignment {
name: string;
member: string;
member_name: string;
member_username: string;
member_image: string;
badge: string;
issued_on: string;
}
export interface BadgeAssignments {
data: BadgeAssignment[],
reload: () => void
insert: {
submit: (
data: BadgeAssignment,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
setValue: {
submit: (
data: BadgeAssignment,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
}

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

@@ -5,10 +5,7 @@
{{ __('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') }}
@@ -118,8 +115,8 @@ import {
HeadsetIcon,
EllipsisVertical,
} from 'lucide-vue-next'
import { inject, ref, getCurrentInstance } from 'vue'
import { formatTime } from '../utils'
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'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
@@ -163,6 +160,12 @@ const openEvalCall = (evl) => {
window.open(evl.google_meet_link, '_blank')
}
const evaluationCourses = computed(() => {
return props.courses.filter((course) => {
return course.evaluator != ''
})
})
const cancelEvaluation = (evl) => {
$dialog({
title: __('Cancel this evaluation?'),

View File

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

View File

@@ -1,80 +1,163 @@
<template>
<div ref="videoContainer" class="video-block relative group">
<video
@timeupdate="updateTime"
@ended="videoEnded"
@click="togglePlay"
oncontextmenu="return false"
class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
<div
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 v-if="quizzes.length && !showQuiz && readOnly" class="leading-5">
{{
__('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 }}. <span class="font-semibold"> {{ quiz.quiz }} </span>
</span>
{{ __('at {0} minutes').format(formatTimestamp(quiz.time)) }}
</div>
</div>
<div
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
:class="{
'invisible group-hover:visible': playing,
}"
v-if="!showQuiz"
ref="videoContainer"
class="video-block relative group"
>
<Button variant="ghost">
<template #icon>
<Play
v-if="!playing"
@click="playVideo"
class="size-4 text-ink-gray-9"
<video
@timeupdate="updateTime"
@ended="videoEnded"
@click="togglePlay"
oncontextmenu="return false"
class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef"
: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>
<div class="relative flex items-center w-full flex-1">
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider h-1"
/>
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
</template>
</Button>
<Button variant="ghost" @click="toggleMute">
<template #icon>
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
<VolumeX v-else class="size-5 text-ink-white" />
</template>
</Button>
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider w-full h-1"
/>
<span class="text-sm font-semibold">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
<Button variant="ghost" @click="toggleFullscreen">
<template #icon>
<Maximize class="size-5 text-ink-white" />
</template>
<!-- QUIZ MARKERS -->
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
<div
v-for="(quiz, index) in quizzes"
:key="index"
:style="getQuizMarkerStyle(quiz.time)"
class="absolute top-0 h-full w-2 bg-surface-amber-3"
></div>
</div>
</div>
<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"
/>
<Dialog
v-model="showQuizLoader"
:options="{
size: 'sm',
}"
>
<template #body>
<div class="flex flex-col space-y-2 p-5 text-base leading-5">
<span class="font-semibold">
{{ __('Time for a Quiz') }}
</span>
<span>
{{
__(
'Complete the upcoming quiz to continue watching the video. The quiz will open in {0} {1}.'
).format(quizLoadTimer, quizLoadTimer === 1 ? 'second' : 'seconds')
}}
</span>
</div>
</template>
</Dialog>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui'
import { Button, Dialog } from 'frappe-ui'
import { formatSeconds, formatTimestamp } from '@/utils'
import { useSettings } from '@/stores/settings'
import Play from '@/components/Icons/Play.vue'
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
const videoRef = ref(null)
const videoContainer = ref(null)
@@ -82,6 +165,13 @@ 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 showQuizLoader = ref(false)
const quizLoadTimer = ref(0)
const currentQuiz = ref(null)
const nextQuiz = ref({})
const { preventSkippingVideos } = useSettings()
const props = defineProps({
file: {
@@ -92,34 +182,94 @@ const props = defineProps({
type: String,
default: 'video/mp4',
},
readOnly: {
type: Boolean,
default: true,
},
quizzes: {
type: Array,
default: () => [],
},
saveQuizzes: {
type: Function,
default: () => {},
},
})
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
quizLoadTimer.value = 7
}
}
}, 0)
}
watch(quizLoadTimer, () => {
if (quizLoadTimer.value > 0) {
showQuizLoader.value = true
setTimeout(() => {
quizLoadTimer.value -= 1
}, 1000)
} else {
showQuizLoader.value = false
showQuiz.value = true
}
})
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
@@ -148,13 +298,13 @@ const toggleMute = () => {
}
const changeCurrentTime = () => {
if (
preventSkippingVideos.data &&
currentTime.value > videoRef.value.currentTime
)
return
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 = () => {
@@ -164,6 +314,13 @@ const toggleFullscreen = () => {
videoContainer.value.requestFullscreen()
}
}
const getQuizMarkerStyle = (time) => {
const percentage = ((time - 5) / Math.ceil(duration.value)) * 100
return {
left: `${percentage}%`,
}
}
</script>
<style scoped>
@@ -183,11 +340,10 @@ iframe {
}
.duration-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
border-radius: 10px;
background-color: theme('colors.gray.100');
background-color: theme('colors.gray.600');
cursor: pointer;
}
@@ -195,20 +351,20 @@ iframe {
width: 2px;
border-radius: 50%;
-webkit-appearance: none;
background-color: theme('colors.gray.500');
background-color: theme('colors.white');
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
input[type='range'] {
overflow: hidden;
width: 150px;
width: 100%;
-webkit-appearance: none;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
cursor: pointer;
box-shadow: -500px 0 0 500px theme('colors.gray.600');
box-shadow: -500px 0 0 500px theme('colors.white');
}
}
</style>

11
frontend/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
export {}
declare global {
function __(text: string): string
}
declare module 'vue' {
interface ComponentCustomProperties {
__: (text: string) => string
}
}

View File

@@ -1,2 +1,3 @@
@import './assets/Inter/inter.css';
@import 'frappe-ui/src/style.css';
@import './styles/codemirror.css';

View File

@@ -145,7 +145,6 @@ const submissions = createListResource({
},
})
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
watch([assignmentID, member, status], () => {
router.push({
query: {

View File

@@ -16,20 +16,17 @@
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New') }}
{{ __('Create') }}
</Button>
</header>
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
<div class="flex items-center justify-between mb-5">
<div
v-if="assignmentCount"
class="text-xl font-semibold text-ink-gray-7 mb-4"
>
<div v-if="assignmentCount" class="text-lg font-semibold text-ink-gray-9">
{{ __('{0} Assignments').format(assignmentCount) }}
</div>
<div
v-if="assignments.data?.length || assigmentCount > 0"
v-if="assignments.data?.length || assignmentCount > 0"
class="grid grid-cols-2 gap-5"
>
<FormControl

View File

@@ -70,7 +70,10 @@
<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" />
@@ -121,7 +124,7 @@
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-4 text-ink-gray-7">
<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) }} -
@@ -130,7 +133,7 @@
</div>
<div
v-if="batch.data.timezone"
class="flex items-center mb-4 text-ink-gray-7"
class="flex items-center mb-3 text-ink-gray-7"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span>

View File

@@ -4,9 +4,16 @@
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" />
<Button variant="solid" @click="saveBatch()">
{{ __('Save') }}
</Button>
<div class="flex items-center space-x-2">
<Button v-if="batchDetail.data?.name" @click="deleteBatch">
<template #icon>
<Trash2 class="size-4 stroke-1.5" />
</template>
</Button>
<Button variant="solid" @click="saveBatch()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="py-5">
<div class="px-20 pb-5 space-y-5 border-b mb-5">
@@ -23,10 +30,10 @@
/>
<MultiSelect
v-model="instructors"
doctype="User"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close) => openSettings('Members', close)"
:onCreate="(close) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
</div>
@@ -159,6 +166,16 @@
}
"
/>
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batch.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
</div>
<div class="space-y-5">
<FormControl
@@ -199,7 +216,10 @@
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<div
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
@click="openFileSelector"
>
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
@@ -263,16 +283,38 @@
/>
</div>
</div>
<div class="px-20 pb-5 space-y-5 border-b">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('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>
</template>
<script setup>
import {
computed,
onMounted,
getCurrentInstance,
inject,
reactive,
onMounted,
onBeforeUnmount,
reactive,
ref,
} from 'vue'
import {
@@ -284,20 +326,30 @@ import {
createResource,
usePageMeta,
toast,
call,
Toast,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { Image } from 'lucide-vue-next'
import { Image, Trash2 } 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 } from '@/utils'
import {
openSettings,
getMetaInfo,
updateMetaInfo,
validateFile,
} from '@/utils'
const router = useRouter()
const user = inject('$user')
const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({
batchName: {
@@ -327,20 +379,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' &&
@@ -454,7 +515,7 @@ const createNewBatch = () => {
localStorage.setItem('firstBatch', data.name)
})
}
updateMetaInfo('batches', data.name, meta)
capture('batch_created')
router.push({
name: 'BatchDetail',
@@ -475,6 +536,7 @@ const editBatchDetails = () => {
{},
{
onSuccess(data) {
updateMetaInfo('batches', data.name, meta)
router.push({
name: 'BatchDetail',
params: {
@@ -489,6 +551,38 @@ const editBatchDetails = () => {
)
}
const deleteBatch = () => {
$dialog({
title: __('Confirm your action to delete'),
message: __(
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick({ close }) {
trashBatch(close)
close()
},
},
],
})
}
const trashBatch = (close) => {
call('lms.lms.api.delete_batch', {
batch: props.batchName,
}).then(() => {
toast.success(__('Batch deleted successfully'))
close()
router.push({
name: 'Batches',
})
})
}
const saveImage = (file) => {
batch.image = file
}
@@ -497,13 +591,6 @@ const removeImage = () => {
batch.image = null
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
}
}
const breadcrumbs = computed(() => {
let crumbs = [
{

View File

@@ -14,7 +14,7 @@
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New') }}
{{ __('Create') }}
</Button>
</router-link>
</header>

View File

@@ -80,7 +80,7 @@
/>
<FormControl :label="__('City')" v-model="billingDetails.city" />
<FormControl
:label="__('State')"
:label="__('State/Province')"
v-model="billingDetails.state"
/>
</div>
@@ -303,6 +303,7 @@ const validateAddress = () => {
'Gujarat',
'Haryana',
'Himachal Pradesh',
'Jammu and Kashmir',
'Jharkhand',
'Karnataka',
'Kerala',

View File

@@ -12,10 +12,7 @@
</Button>
</router-link>
</header>
<div
v-if="participants.data?.length"
class="mx-auto w-full max-w-4xl pt-6 pb-10"
>
<div class="mx-auto w-full max-w-4xl pt-6 pb-10">
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ memberCount }} {{ __('certified members') }}
@@ -41,7 +38,7 @@
</div>
</div>
</div>
<div class="divide-y">
<div v-if="participants.data?.length" class="divide-y">
<template v-for="participant in participants.data">
<router-link
:to="{
@@ -92,6 +89,7 @@
</router-link>
</template>
</div>
<EmptyState v-else type="Certified Members" />
<div
v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5"
@@ -101,7 +99,6 @@
</Button>
</div>
</div>
<EmptyState v-else type="Certified Members" />
</template>
<script setup>
import {
@@ -127,22 +124,25 @@ 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,
cache: ['certified_participants'],
pageLength: 100,
})
const count = call('lms.lms.api.get_count_of_certified_members').then(
(data) => {
const getMemberCount = () => {
call('lms.lms.api.get_count_of_certified_members', {
filters: filters.value,
}).then((data) => {
memberCount.value = data
}
)
})
}
const categories = createListResource({
doctype: 'LMS Certificate',
@@ -157,6 +157,7 @@ const categories = createListResource({
const updateParticipants = () => {
updateFilters()
getMemberCount()
participants.update({
filters: filters.value,
})

View File

@@ -6,7 +6,7 @@
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div class="m-5">
<div class="flex justify-between w-full">
<div class="flex justify-between w-full space-x-5">
<div class="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ course.data.title }}
@@ -66,7 +66,9 @@
{{ tag }}
</Badge>
</div>
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
<div class="md:hidden mb-4">
<CourseCardOverlay :course="course" />
</div>
<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-10"
@@ -76,6 +78,7 @@
:title="__('Course Outline')"
:courseName="course.data.name"
:showOutline="true"
:getProgress="course.data.membership ? true : false"
/>
</div>
<CourseReviews
@@ -88,6 +91,7 @@
<CourseCardOverlay :course="course" />
</div>
</div>
<RelatedCourses :courseName="course.data.name" />
</div>
</div>
</template>
@@ -99,7 +103,7 @@ import {
Tooltip,
usePageMeta,
} from 'frappe-ui'
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { Users, Star } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
@@ -107,6 +111,7 @@ import CourseOutline from '@/components/CourseOutline.vue'
import CourseReviews from '@/components/CourseReviews.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import RelatedCourses from '@/components/RelatedCourses.vue'
const { brand } = sessionStore()
@@ -120,12 +125,21 @@ const props = defineProps({
const course = createResource({
url: 'lms.lms.utils.get_course_details',
cache: ['course', props.courseName],
params: {
course: props.courseName,
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
course.reload()
}
)
const breadcrumbs = computed(() => {
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({

View File

@@ -47,48 +47,37 @@
:required="true"
/>
<div>
<div class="mb-1.5 text-xs text-ink-gray-5">
<div class="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)"
/>
<FormControl
v-model="newTag"
:placeholder="__('Add a keyword and then press enter')"
:class="['w-full', 'flex-1', 'my-1']"
@keyup.enter="updateTags()"
id="tags"
/>
<div>
<div class="flex items-center flex-wrap gap-2">
<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"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
@click="removeTag(tag)"
/>
</div>
</div>
<FormControl
v-model="newTag"
:placeholder="__('Add a keyword and then press enter')"
class="w-full"
@keyup.enter="updateTags()"
id="tags"
/>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="course.short_introduction"
type="textarea"
:rows="4"
: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"
@@ -100,7 +89,10 @@
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<div
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
@click="openFileSelector"
>
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
@@ -135,6 +127,13 @@
</div>
</div>
</div>
<ColorSwatches
v-model="course.card_gradient"
:label="__('Color')"
:description="__('Choose a color for the course card')"
class="w-full"
/>
</div>
</div>
@@ -176,6 +175,21 @@
</div>
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold">
{{ __('About the Course') }}
</div>
<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="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Course Description') }}
@@ -199,9 +213,24 @@
)
"
/>
<MultiSelect
v-model="related_courses"
doctype="LMS Course"
:label="__('Related Courses')"
:filters="{ name: ['!=', courseResource.data?.name] }"
:onCreate="
(close) => {
router.push({
name: 'CourseForm',
params: { courseName: 'new' },
})
}
"
/>
</div>
<div class="px-10 pb-5 space-y-5">
<div class="px-10 pb-5 space-y-5 border-b">
<div class="text-lg font-semibold mt-5">
{{ __('Pricing and Certification') }}
</div>
@@ -248,6 +277,27 @@
/>
</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>
<div class="border-l">
@@ -264,6 +314,7 @@
<script setup>
import {
Breadcrumbs,
call,
TextEditor,
Button,
createResource,
@@ -284,19 +335,26 @@ import {
} from 'vue'
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 { openSettings } from '@/utils'
import {
openSettings,
getMetaInfo,
updateMetaInfo,
validateFile,
} from '@/utils'
import Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import ColorSwatches from '@/components/Controls/ColorSwatches.vue'
const user = inject('$user')
const newTag = ref('')
const { brand } = sessionStore()
const router = useRouter()
const instructors = ref([])
const related_courses = ref([])
const app = getCurrentInstance()
const { updateOnboardingStep } = useOnboarding('learning')
const { $dialog } = app.appContext.config.globalProperties
@@ -313,6 +371,7 @@ const course = reactive({
description: '',
video_link: '',
course_image: null,
card_gradient: '',
tags: '',
category: '',
published: false,
@@ -328,19 +387,30 @@ const course = reactive({
evaluator: '',
})
const meta = reactive({
description: '',
keywords: '',
})
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
if (props.courseName !== 'new') {
courseResource.reload()
fetchCourseInfo()
} else {
capture('course_form_opened')
startRecording()
}
window.addEventListener('keydown', keyboardShortcut)
})
const fetchCourseInfo = () => {
courseResource.reload()
getMetaInfo('courses', props.courseName, meta)
}
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
@@ -354,6 +424,7 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
stopRecording()
})
const courseCreationResource = createResource({
@@ -366,6 +437,9 @@ const courseCreationResource = createResource({
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
...values,
},
}
@@ -384,6 +458,9 @@ const courseEditResource = createResource({
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
...course,
},
}
@@ -406,6 +483,11 @@ const courseResource = createResource({
data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (key == 'related_courses') {
related_courses.value = []
data.related_courses.forEach((course) => {
related_courses.value.push(course.course)
})
} else if (Object.hasOwn(course, key)) course[key] = data[key]
})
let checkboxes = [
@@ -442,40 +524,50 @@ const imageResource = createResource({
const submitCourse = () => {
if (courseResource.data) {
courseEditResource.submit(
{
course: courseResource.data.name,
},
{
onSuccess() {
toast.success(__('Course updated successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
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')
toast.success(__('Course created successfully'))
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) {
toast.error(err.messages?.[0] || err)
},
})
}
}
)
}
const deleteCourse = createResource({
@@ -515,18 +607,11 @@ watch(
() => props.courseName !== 'new',
(newVal) => {
if (newVal) {
courseResource.reload()
fetchCourseInfo()
}
}
)
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return __('Only image file is allowed.')
}
}
const updateTags = () => {
if (newTag.value) {
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value

View File

@@ -14,7 +14,7 @@
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New') }}
{{ __('Create') }}
</Button>
</router-link>
</header>
@@ -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-4"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-8"
>
<router-link
v-for="course in courses.data"
@@ -240,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'] = [
@@ -249,6 +248,8 @@ const updateTabFilter = () => {
]
} else if (currentTab.value == 'Created') {
filters.value['created'] = 1
} else if (currentTab.value == 'Unpublished') {
filters.value['published'] = 0
}
}
}
@@ -318,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

@@ -151,7 +151,7 @@ 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 } from '@/utils'
import { getFileSize, validateFile } from '@/utils'
const user = inject('$user')
const router = useRouter()
@@ -293,13 +293,6 @@ const removeImage = () => {
job.image = null
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
}
}
const jobTypes = computed(() => {
return [
{ label: 'Full Time', value: 'Full Time' },

View File

@@ -26,7 +26,6 @@
</header>
<div>
<div
v-if="jobCount"
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">
@@ -34,8 +33,8 @@
</div>
<div
v-if="jobs.data?.length || jobCount > 0"
class="grid grid-cols-1 md:grid-cols-3 gap-2"
class="grid grid-cols-1 gap-2"
:class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'"
>
<FormControl
type="text"
@@ -52,6 +51,7 @@
</template>
</FormControl>
<Link
v-if="user.data"
doctype="Country"
v-model="country"
:placeholder="__('Country')"
@@ -117,7 +117,6 @@ onMounted(() => {
jobType.value = queries.get('type')
}
updateJobs()
getJobCount()
})
const jobs = createResource({
@@ -163,22 +162,14 @@ const updateFilters = () => {
}
}
const getJobCount = () => {
call('frappe.client.get_count', {
doctype: 'Job Opportunity',
filters: {
status: 'Open',
disabled: 0,
},
}).then((data) => {
jobCount.value = data
})
}
watch(country, (val) => {
updateJobs()
})
watch(jobs, () => {
jobCount.value = jobs.data?.length || 0
})
const jobTypes = computed(() => {
return [
'',

View File

@@ -12,6 +12,12 @@
</template>
</Button>
</Tooltip>
<Button v-if="canSeeStats()" @click="showVideoStats()">
<template #prefix>
<TrendingUp class="size-4 stroke-1.5" />
</template>
{{ __('Video Statistics') }}
</Button>
<CertificationLinks :courseName="courseName" />
</div>
</header>
@@ -63,55 +69,48 @@
}"
>
<div
class="border-r container pt-5 pb-10 px-5 h-full"
class="border-r pt-5 pb-10 h-full"
:class="{
'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
'w-full md:w-3/5 mx-auto border-none !pt-10': zenModeEnabled,
}"
>
<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 class="px-5">
<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"
v-if="zenModeEnabled"
class="relative flex items-center space-x-2 text-sm mt-1 text-ink-gray-7 group w-fit mt-2"
>
{{ Math.ceil(lesson.data.membership.progress) }}%
{{ __('completed') }}
<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>
<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>
<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>
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
@@ -119,34 +118,24 @@
{{ __('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>
<router-link
v-if="allowEdit()"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
}"
>
<Button>
{{ __('Edit') }}
</Button>
</router-link>
<Button v-if="lesson.data.next" @click="switchLesson('next')">
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
@@ -154,85 +143,106 @@
{{ __('Next') }}
</span>
</Button>
</router-link>
<router-link
v-else
:to="{
name: 'CourseDetail',
params: { courseName: courseName },
<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,
}"
>
<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"
<UserAvatar
v-for="instructor in lesson.data.instructors"
:user="instructor"
/>
</span>
<CourseInstructors
v-if="lesson.data?.instructors"
:instructors="lesson.data.instructors"
/>
</span>
<CourseInstructors
v-if="lesson.data?.instructors"
:instructors="lesson.data.instructors"
/>
</div>
</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
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>
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"
@mouseup="toggleInlineMenu"
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>
<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-8"
v-if="lesson.data"
class="mt-10 pt-5 border-t px-5"
ref="discussionsContainer"
>
<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"
<TabButtons
:buttons="tabs"
v-model="currentTab"
class="w-fit mb-10"
/>
<Notes
v-if="currentTab === 'Notes'"
:lesson="lesson.data?.name"
v-model:notes="notes"
@updateNotes="updateNotes"
/>
</div>
<div class="mt-20" ref="discussionsContainer">
<Discussions
v-if="allowDiscussions"
v-else-if="allowDiscussions"
:title="'Questions'"
:doctype="'Course Lesson'"
:docname="lesson.data.name"
:key="lesson.data.name"
:emptyStateText="
__('Ask a question to get help from the community.')
"
/>
</div>
</div>
@@ -262,13 +272,28 @@
</div>
</div>
</div>
<InlineLessonMenu
v-if="lesson.data"
v-model="showInlineMenu"
:lesson="lesson.data?.name"
v-model:notes="notes"
@updateNotes="updateNotes"
/>
<VideoStatistics
v-model="showStatsDialog"
:lessonName="lesson.data?.name"
:lessonTitle="lesson.data?.title"
/>
</template>
<script setup>
import {
createResource,
Badge,
Breadcrumbs,
Button,
call,
createListResource,
createResource,
TabButtons,
Tooltip,
usePageMeta,
} from 'frappe-ui'
@@ -281,8 +306,6 @@ import {
onBeforeUnmount,
nextTick,
} from 'vue'
import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter, useRoute } from 'vue-router'
import {
ChevronLeft,
@@ -292,17 +315,25 @@ import {
Focus,
Info,
MessageCircleQuestion,
TrendingUp,
} from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue'
import { getEditorTools, enablePlyr } from '@/utils'
import { getEditorTools, enablePlyr, highlightText } from '@/utils'
import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
import EditorJS from '@editorjs/editorjs'
import LessonContent from '@/components/LessonContent.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import Discussions from '@/components/Discussions.vue'
import CertificationLinks from '@/components/CertificationLinks.vue'
import VideoStatistics from '@/components/Modals/VideoStatistics.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Notes from '@/components/Notes/Notes.vue'
import InlineLessonMenu from '@/components/Notes/InlineLessonMenu.vue'
const user = inject('$user')
const socket = inject('$socket')
const router = useRouter()
const route = useRoute()
const allowDiscussions = ref(false)
@@ -311,12 +342,24 @@ const instructorEditor = ref(null)
const lessonProgress = ref(0)
const lessonContainer = ref(null)
const zenModeEnabled = ref(false)
const showStatsDialog = ref(false)
const hasQuiz = ref(false)
const discussionsContainer = ref(null)
const timer = ref(0)
const { brand } = sessionStore()
const sidebarStore = useSidebar()
const plyrSources = ref([])
const showInlineMenu = ref(false)
const currentTab = ref('Notes')
let timerInterval
const tabs = ref([
{
label: __('Notes'),
value: 'Notes',
},
])
const props = defineProps({
courseName: {
type: String,
@@ -334,7 +377,13 @@ const props = defineProps({
onMounted(() => {
startTimer()
sidebarStore.isSidebarCollapsed = true
document.addEventListener('fullscreenchange', attachFullscreenEvent)
socket.on('update_lesson_progress', (data) => {
if (data.course === props.courseName) {
lessonProgress.value = data.progress
}
})
})
const attachFullscreenEvent = () => {
@@ -351,6 +400,8 @@ const attachFullscreenEvent = () => {
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
sidebarStore.isSidebarCollapsed = false
trackVideoWatchDuration()
})
const lesson = createResource({
@@ -386,16 +437,22 @@ const setupLesson = (data) => {
editor.value?.isReady.then(() => {
checkIfDiscussionsAllowed()
})
checkQuiz()
}
if (!editor.value && data.body) {
const checkQuiz = () => {
if (!editor.value && lesson.body) {
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
hasQuiz.value = quizRegex.test(data.body)
if (!hasQuiz.value && !zenModeEnabled) allowDiscussions.value = true
hasQuiz.value = quizRegex.test(lesson.body)
if (!hasQuiz.value && !zenModeEnabled) {
allowDiscussions.value = true
} else {
allowDiscussions.value = false
}
}
}
const renderEditor = (holder, content) => {
// empty the holder
if (document.getElementById(holder))
document.getElementById(holder).innerHTML = ''
return new EditorJS({
@@ -403,7 +460,7 @@ const renderEditor = (holder, content) => {
tools: getEditorTools(),
data: JSON.parse(content),
readOnly: true,
defaultBlock: 'embed', // editor adds an empty block at the top, so to avoid that added default block as embed
defaultBlock: 'embed',
})
}
@@ -426,6 +483,23 @@ const progress = createResource({
},
})
const notes = createListResource({
doctype: 'LMS Lesson Note',
filters: {
lesson: lesson.data?.name,
member: user.data?.name,
},
fields: ['name', 'color', 'highlighted_text', 'note'],
cache: ['notes', lesson.data?.name, user.data?.name],
onSuccess(data) {
data.forEach((note) => {
setTimeout(() => {
highlightText(note)
}, 500)
})
},
})
const breadcrumbs = computed(() => {
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({
@@ -446,38 +520,163 @@ const breadcrumbs = computed(() => {
return items
})
const switchLesson = (direction) => {
trackVideoWatchDuration()
let lessonIndex =
direction === 'prev'
? lesson.data.prev.split('.')
: lesson.data.next.split('.')
router.push({
name: 'Lesson',
params: {
courseName: props.courseName,
chapterNumber: lessonIndex[0],
lessonNumber: lessonIndex[1],
},
})
}
watch(
[() => route.params.chapterNumber, () => route.params.lessonNumber],
(
async (
[newChapterNumber, newLessonNumber],
[oldChapterNumber, oldLessonNumber]
) => {
if (newChapterNumber || newLessonNumber) {
editor.value = null
instructorEditor.value = null
allowDiscussions.value = false
lesson.submit({
chapter: newChapterNumber,
lesson: newLessonNumber,
})
clearInterval(timerInterval)
timer.value = 0
plyrSources.value = []
await nextTick()
resetLessonState(newChapterNumber, newLessonNumber)
startTimer()
enablePlyr()
updateNotes()
checkIfDiscussionsAllowed()
checkQuiz()
}
}
)
const resetLessonState = (newChapterNumber, newLessonNumber) => {
editor.value = null
instructorEditor.value = null
allowDiscussions.value = false
lesson.submit({
chapter: newChapterNumber,
lesson: newLessonNumber,
})
clearInterval(timerInterval)
timer.value = 0
}
const trackVideoWatchDuration = () => {
if (!lesson.data.membership) return
let videoDetails = getVideoDetails()
videoDetails = videoDetails.concat(getPlyrSourceDetails())
call('lms.lms.api.track_video_watch_duration', {
lesson: lesson.data.name,
videos: videoDetails,
})
}
const getVideoDetails = () => {
let details = []
const videos = document.querySelectorAll('video')
if (videos.length > 0) {
videos.forEach((video) => {
if (video.currentTime == video.duration) markProgress()
details.push({
source: video.src,
watch_time: video.currentTime,
})
})
}
return details
}
const getPlyrSourceDetails = () => {
let details = []
plyrSources.value.forEach((source) => {
if (source.currentTime == source.duration) markProgress()
let src = cleanYouTubeUrl(source.source)
details.push({
source: src,
watch_time: source.currentTime,
})
})
return details
}
const cleanYouTubeUrl = (url) => {
if (!url) return url
const urlObj = new URL(url)
urlObj.searchParams.delete('t')
return urlObj.toString()
}
watch(
() => lesson.data,
(data) => {
async (data) => {
setupLesson(data)
enablePlyr()
getPlyrSource()
updateNotes()
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
}
)
const getPlyrSource = async () => {
await nextTick()
if (plyrSources.value.length == 0) {
plyrSources.value = await enablePlyr()
}
updateVideoWatchDuration()
}
const updateVideoWatchDuration = () => {
if (lesson.data.videos && lesson.data.videos.length > 0) {
lesson.data.videos.forEach((video) => {
if (video.source.includes('youtube') || video.source.includes('vimeo')) {
updatePlyrVideoTime(video)
} else {
updateVideoTime(video)
}
})
}
}
const updatePlyrVideoTime = (video) => {
plyrSources.value.forEach((plyrSource) => {
let lastWatchedTime = 0
let isSeeking = false
plyrSource.on('ready', () => {
if (plyrSource.source === video.source) {
plyrSource.embed.seekTo(video.watch_time, true)
plyrSource.play()
plyrSource.pause()
}
})
})
}
const updateVideoTime = (video) => {
const videos = document.querySelectorAll('video')
if (videos.length > 0) {
videos.forEach((vid) => {
if (vid.src === video.source) {
let watch_time = video.watch_time < vid.duration ? video.watch_time : 0
if (vid.readyState >= 1) {
vid.currentTime = watch_time
} else {
vid.addEventListener('loadedmetadata', () => {
vid.currentTime = watch_time
})
}
}
})
}
}
const startTimer = () => {
timerInterval = setInterval(() => {
let timerInterval = setInterval(() => {
timer.value++
if (timer.value == 30) {
clearInterval(timerInterval)
@@ -491,8 +690,11 @@ onBeforeUnmount(() => {
})
const checkIfDiscussionsAllowed = () => {
hasQuiz.value = false
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
if (block.type === 'quiz') hasQuiz.value = true
if (block.type === 'quiz') {
hasQuiz.value = true
}
})
if (
@@ -501,8 +703,11 @@ const checkIfDiscussionsAllowed = () => {
(lesson.data?.membership ||
user.data?.is_moderator ||
user.data?.is_instructor)
)
) {
allowDiscussions.value = true
} else {
allowDiscussions.value = false
}
}
const allowEdit = () => {
@@ -542,13 +747,31 @@ const enrollStudent = () => {
)
}
const toggleInlineMenu = async () => {
showInlineMenu.value = false
await nextTick()
let selection = window.getSelection()
if (selection.toString()) {
showInlineMenu.value = true
}
}
const canSeeStats = () => {
if (user.data?.is_moderator || user.data?.is_instructor) return true
return false
}
const showVideoStats = () => {
showStatsDialog.value = true
}
const canGoZen = () => {
if (
user.data?.is_moderator ||
user.data?.is_instructor ||
user.data?.is_evaluator
)
return false
return true
if (lesson.data?.membership) return true
return false
}
@@ -584,6 +807,38 @@ const scrollDiscussionsIntoView = () => {
})
}
const updateNotes = () => {
notes.update({
filters: {
lesson: lesson.data?.name,
member: user.data?.name,
},
})
notes.reload()
}
watch(allowDiscussions, () => {
if (allowDiscussions.value) {
tabs.value = [
{
label: __('Notes'),
value: 'Notes',
},
{
label: __('Community'),
value: 'Community',
},
]
} else {
tabs.value = [
{
label: __('Notes'),
value: 'Notes',
},
]
}
})
const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
}

View File

@@ -99,7 +99,7 @@ import EditorJS from '@editorjs/editorjs'
import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next'
import { getEditorTools, enablePlyr } from '@/utils'
import { capture } from '@/telemetry'
import { capture, startRecording, stopRecording } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe'
const { brand } = sessionStore()
@@ -131,6 +131,7 @@ onMounted(() => {
window.location.href = '/login'
}
capture('lesson_form_opened')
startRecording()
editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes')
window.addEventListener('keydown', keyboardShortcut)
@@ -226,6 +227,7 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => {
clearInterval(autoSaveInterval)
window.removeEventListener('keydown', keyboardShortcut)
stopRecording()
})
const newLessonResource = createResource({
@@ -383,8 +385,10 @@ const saveLesson = (e) => {
showSuccessMessage = true
}
editor.value.save().then((outputData) => {
outputData = removeEmptyBlocks(outputData)
lesson.content = JSON.stringify(outputData)
instructorEditor.value.save().then((outputData) => {
outputData = removeEmptyBlocks(outputData)
lesson.instructor_content = JSON.stringify(outputData)
if (lessonDetails.data?.lesson) {
editCurrentLesson()
@@ -395,6 +399,14 @@ const saveLesson = (e) => {
})
}
const removeEmptyBlocks = (outputData) => {
let blocks = outputData.blocks.filter((block) => {
return Object.keys(block.data).length > 0 || block.type == 'paragraph'
})
outputData.blocks = blocks
return outputData
}
const createNewLesson = () => {
newLessonResource.submit(
{},
@@ -651,6 +663,57 @@ iframe {
border-radius: 8px;
}
.ce-popover__container {
border-radius: 12px;
padding: 8px;
}
.cdx-search-field {
border: none;
}
.cdx-search-field__input {
font-weight: 400;
font-size: 13px;
}
.cdx-search-field__input::before {
font-weight: 400;
}
.cdx-search-field__input:focus {
--tw-ring-color: theme('colors.gray.100');
}
.ce-popover-item__title {
font-size: 13px;
font-weight: 400;
}
.ce-popover-item__icon svg {
width: 15px;
height: 15px;
}
.ce-popover--opened > .ce-popover__container {
max-height: unset;
}
.cdx-search-field__icon svg {
width: 15px;
height: 15px;
}
.cdx-search-field__icon {
margin-right: 5px;
}
.cdx-block.embed-tool {
position: relative;
display: inline-block;
width: 100%;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;

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

@@ -72,7 +72,7 @@ const persona = reactive({
const submitPersona = () => {
let responses = {
site: user.data?.sitename,
no_of_students: persona.noOfStudents,
role: persona.role,
use_case: persona.useCase,
}
call('lms.lms.api.capture_user_persona', {

View File

@@ -80,7 +80,7 @@
v-model="activeTab"
/>
</div>
<router-view :profile="profile" />
<router-view :profile="profile" :key="profile.data?.name" />
</div>
</div>
<EditProfile

View File

@@ -58,6 +58,7 @@ const evaluations = createListResource({
doctype: 'LMS Certificate Request',
filters: {
evaluator: user.data?.name,
status: ['!=', 'Cancelled'],
},
fields: [
'name',
@@ -76,7 +77,7 @@ const evaluations = createListResource({
],
auto: true,
orderBy: 'creation desc',
limit: 100,
pageLength: 500,
cache: ['schedule', user.data?.name],
transform(data) {
return data.map((d) => {

View File

@@ -45,7 +45,7 @@
</template>
<script setup>
import { FormControl, createResource, toast } from 'frappe-ui'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils'
import { CircleAlert } from 'lucide-vue-next'
@@ -66,10 +66,9 @@ const roles = createResource({
url: 'lms.lms.utils.get_roles',
makeParams(values) {
return {
name: props.profile.data?.name,
name: values.member,
}
},
auto: true,
onSuccess(data) {
let roles = [
'moderator',
@@ -83,6 +82,16 @@ const roles = createResource({
},
})
watch(
() => props.profile,
(newValue) => {
roles.reload({
member: newValue.data?.name,
})
},
{ immediate: true }
)
const updateRole = createResource({
url: 'lms.lms.api.save_role',
makeParams(values) {
@@ -97,7 +106,10 @@ const updateRole = createResource({
const changeRole = (role) => {
updateRole.submit(
{
role: convertToTitleCase(role.split('_').join(' ')),
role:
role == 'lms_student'
? 'LMS Student'
: convertToTitleCase(role.split('_').join(' ')),
value: eval(role).value,
},
{

View File

@@ -0,0 +1,255 @@
<template>
<Dialog v-model="show" :options="{ size: '5xl' }">
<template #body-title>
<div class="text-xl font-semibold text-ink-gray-9">
{{
props.exerciseID === 'new'
? __('Create Programming Exercise')
: __('Edit Programming Exercise')
}}
</div>
</template>
<template #body-content>
<div class="grid grid-cols-2 gap-10">
<div class="space-y-4">
<FormControl
v-model="exercise.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="exercise.language"
:label="__('Language')"
type="select"
:options="languageOptions"
:required="true"
/>
<ChildTable
v-model="exercise.test_cases"
:label="__('Test Cases')"
:columns="testCaseColumns"
:required="true"
:addable="true"
:deletable="true"
:editable="true"
:placeholder="__('Add Test Case')"
/>
</div>
<div>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Problem Statement') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="exercise.problem_statement"
@change="(val: string) => (exercise.problem_statement = 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-[21rem] overflow-y-auto"
/>
</div>
</div>
</div>
</template>
<template #actions="{ close }">
<div class="flex justify-end space-x-2 group">
<Button
v-if="exerciseID != 'new'"
@click="deleteExercise(close)"
variant="outline"
theme="red"
class="invisible group-hover:visible"
>
<template #prefix>
<Trash2 class="size-4 stroke-1.5" />
</template>
{{ __('Delete') }}
</Button>
<router-link
:to="{
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: props.exerciseID,
submissionID: 'new',
},
}"
>
<Button>
<template #prefix>
<Play class="size-4 stroke-1.5" />
</template>
{{ __('Test this Exercise') }}
</Button>
</router-link>
<router-link
:to="{
name: 'ProgrammingExerciseSubmissions',
query: {
exercise: props.exerciseID,
},
}"
>
<Button>
<template #prefix>
<ClipboardList class="size-4 stroke-1.5" />
</template>
{{ __('Check Submission') }}
</Button>
</router-link>
<Button variant="solid" @click="saveExercise(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Button,
createListResource,
Dialog,
FormControl,
TextEditor,
toast,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import {
ProgrammingExercise,
ProgrammingExercises,
TestCase,
} from '@/types/programming-exercise'
import ChildTable from '@/components/Controls/ChildTable.vue'
import { ClipboardList, Play, Trash2 } from 'lucide-vue-next'
const show = defineModel()
const exercises = defineModel<ProgrammingExercises>('exercises')
const exercise = ref<ProgrammingExercise>({
title: '',
language: 'Python',
problem_statement: '',
test_cases: [],
})
const languageOptions = [
{ label: 'Python', value: 'Python' },
{ label: 'JavaScript', value: 'JavaScript' },
]
const props = withDefaults(
defineProps<{
exerciseID: string
}>(),
{
exerciseID: 'new',
}
)
watch(
() => props.exerciseID,
() => {
setExerciseData()
fetchTestCases()
}
)
const setExerciseData = () => {
let isNew = true
exercises.value?.data.forEach((ex: ProgrammingExercise) => {
if (ex.name === props.exerciseID) {
isNew = false
exercise.value = { ...ex }
}
})
if (isNew) {
exercise.value = {
title: '',
language: 'Python',
problem_statement: '',
test_cases: [],
}
}
}
const testCases = createListResource({
doctype: 'LMS Test Case',
fields: ['input', 'expected_output', 'name'],
cache: ['testCases', props.exerciseID],
parent: 'LMS Programming Exercise',
onSuccess(data: TestCase[]) {
exercise.value.test_cases = data
},
})
const fetchTestCases = () => {
testCases.update({
filters: {
parent: props.exerciseID,
parenttype: 'LMS Programming Exercise',
parentfield: 'test_cases',
},
})
testCases.reload()
}
const saveExercise = (close: () => void) => {
if (props.exerciseID == 'new') createNewExercise(close)
else updateExercise(close)
}
const createNewExercise = (close: () => void) => {
exercises.value?.insert.submit(
{
...exercise.value,
},
{
onSuccess() {
close()
exercises.value?.reload()
toast.success(__('Programming Exercise created successfully'))
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
}
const updateExercise = (close: () => void) => {
exercises.value?.setValue.submit(
{
name: props.exerciseID,
...exercise.value,
},
{
onSuccess() {
close()
exercises.value?.reload()
toast.success(__('Programming Exercise updated successfully'))
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
}
const testCaseColumns = computed(() => {
return ['Input', 'Expected Output']
})
const deleteExercise = (close: () => void) => {
if (props.exerciseID == 'new') return
exercises.value?.delete.submit(props.exerciseID, {
onSuccess() {
toast.success(__('Programming Exercise deleted successfully'))
close()
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
})
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add a programming exercise to your lesson'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: () => {
saveExercise()
},
},
],
}"
>
<template #body-content>
<div class="text-base">
<Link
v-model="exercise"
doctype="LMS Programming Exercise"
:label="__('Select a Programming Exercise')"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Dialog } from 'frappe-ui'
import { onMounted, nextTick, ref } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const exercise = ref(null)
const props = defineProps({
onSave: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const saveExercise = () => {
props.onSave(exercise.value)
show.value = false
}
</script>

View File

@@ -0,0 +1,430 @@
<template>
<header
v-if="!fromLesson"
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" />
</header>
<div
v-if="falconError"
class="flex items-center justify-between p-3 text-sm bg-surface-amber-1 text-ink-amber-3"
>
<span>
{{ falconError }}
</span>
<Button v-if="user.data?.is_moderator" @click="openSettings('General')">
<template #prefix>
<Settings class="size-4 stroke-1.5" />
</template>
{{ __('Settings') }}
</Button>
</div>
<div class="grid grid-cols-2 h-[calc(100vh_-_3rem)]">
<div class="border-r py-5 px-8 h-full">
<div class="font-semibold mb-2">
{{ __('Problem Statement') }}
</div>
<div
v-html="exercise.doc?.problem_statement"
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>
<div class="flex items-center justify-between p-2 bg-surface-gray-2">
<div class="font-semibold">
{{ exercise.doc?.language }}
</div>
<div class="space-x-2">
<Badge
v-if="submission.doc?.status"
:theme="submission.doc.status == 'Passed' ? 'green' : 'red'"
>
{{ submission.doc.status }}
</Badge>
<Button
v-if="
!falconError &&
(submissionID == 'new' ||
user.data?.name == submission.doc?.owner)
"
variant="solid"
@click="submitCode"
>
<template #prefix>
<Play class="size-3" />
</template>
{{ __('Run') }}
</Button>
</div>
</div>
<div class="flex flex-col space-y-4 pt-5 border-b">
<Code
v-model="code"
:language="exercise.doc?.language.toLowerCase()"
height="400px"
maxHeight="1000px"
/>
<div class="flex flex-col space-y-1">
<span v-if="error" class="text-xs text-ink-gray-5 px-1">
{{ __('Compiler Message') }}:
</span>
<textarea
v-if="error"
v-model="errorMessage"
class="font-mono text-ink-red-3 bg-surface-gray-1 border-none text-sm h-32 leading-6"
readonly
/>
</div>
<!-- <textarea v-else v-model="output" class="bg-surface-gray-1 border-none text-sm h-28 leading-6" readonly /> -->
</div>
<div ref="testCaseSection" class="p-5">
<span class="text-lg font-semibold text-ink-gray-9">
{{ __('Test Cases') }}
</span>
<div v-if="testCases.length" class="divide-y mt-5">
<div
v-for="(testCase, index) in testCases"
:key="testCase.input"
class="py-3"
>
<div class="flex items-center mb-3">
<span class=""> {{ __('Test {0}').format(index + 1) }} - </span>
<span
class="font-semibold ml-2 mr-1"
:class="
testCase.status === 'Passed'
? 'text-ink-green-3'
: 'text-ink-red-3'
"
>
{{ testCase.status }}
</span>
<!-- <span v-if="testCase.status === 'Passed'">
<Check class="size-4 text-ink-green-3" />
</span>
<span v-else>
<X class="size-4 text-ink-red-3" />
</span> -->
</div>
<div class="flex items-center justify-between w-[60%]">
<div v-if="testCase.input" class="space-y-2">
<div class="text-xs text-ink-gray-7">
{{ __('Input') }}
</div>
<div>{{ testCase.input }}</div>
</div>
<div class="space-y-2">
<div class="text-xs text-ink-gray-7">
{{ __('Your Output') }}
</div>
<div>
{{ testCase.output }}
</div>
</div>
<div class="space-y-2">
<div class="text-xs text-ink-gray-7">
{{ __('Expected Output') }}
</div>
<div>{{ testCase.expected_output }}</div>
</div>
</div>
</div>
</div>
<div v-else class="text-sm text-ink-gray-6 mt-4">
{{ __('Please run the code to execute the test cases.') }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
Badge,
Breadcrumbs,
Button,
call,
createDocumentResource,
toast,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Play, X, Check, Settings } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import { openSettings } from '@/utils'
const user = inject<any>('$user')
const code = ref<string | null>('')
const output = ref<string | null>(null)
const error = ref<boolean | null>(null)
const errorMessage = ref<string | null>(null)
const testCaseSection = ref<HTMLElement | null>(null)
const testCases = ref<TestCase[]>([])
const boilerplate = ref<string>('')
const { brand, livecodeURL } = sessionStore()
const router = useRouter()
const fromLesson = ref(false)
const falconURL = ref<string>('https://falcon.frappe.io/')
const falconError = ref<string | null>(null)
const props = withDefaults(
defineProps<{
exerciseID: string
submissionID?: string
}>(),
{
submissionID: 'new',
}
)
onMounted(() => {
loadFalcon()
checkIfUserIsPermitted()
checkIfInLesson()
fetchSubmission()
})
const checkIfInLesson = () => {
if (new URLSearchParams(window.location.search).get('fromLesson')) {
fromLesson.value = true
}
}
const fetchSubmission = (name: string = '') => {
if (name) {
submission.name = name
submission.reload()
} else if (props.submissionID != 'new') {
submission.reload()
}
}
const exercise = createDocumentResource({
doctype: 'LMS Programming Exercise',
name: props.exerciseID,
cache: ['programmingExercise', props.exerciseID],
auto: true,
})
const submission = createDocumentResource({
doctype: 'LMS Programming Exercise Submission',
name: props.submissionID,
auto: false,
onError(error: any) {
if (error.messages?.[0].includes('not found')) {
router.push({
name: 'ProgrammingExerciseSubmission',
params: { exerciseID: props.exerciseID, submissionID: 'new' },
})
} else {
toast.error(__(error.messages?.[0] || error))
}
},
})
watch(exercise, () => {
updateCode()
})
const updateCode = (submissionCode = '') => {
updateBoilerPlate()
if (!code.value?.includes(boilerplate.value)) {
code.value = `${boilerplate.value}${code.value}`
}
if (submissionCode && !code.value?.includes(submissionCode)) {
code.value = `${code.value}${submissionCode}`
} else if (!submissionCode && !code.value) {
code.value = boilerplate.value
}
}
const updateBoilerPlate = () => {
if (exercise.doc?.language == 'Python') {
boilerplate.value = `with open("stdin", "r") as f:\n data = f.read()\n\ninputs = data.split() if len(data) else []\n\n# inputs is a list of strings\n# write your code below\n\n`
} else if (exercise.doc?.language == 'JavaScript') {
boilerplate.value = `const fs = require('fs');\n\nlet input = fs.readFileSync('/app/stdin', 'utf8').trim();\nconst inputs = input.split("\\n");\n// inputs is an array of strings\n// write your code below\n`
}
}
const checkIfUserIsPermitted = (doc: any = null) => {
if (!user.data) {
window.location.href = `/login?redirect-to=/lms/programming-exercises/${props.exerciseID}/submission/${props.submissionID}`
}
if (!doc) return
if (
doc.owner != user.data?.name &&
!user.data?.is_instructor &&
!user.data?.is_moderator &&
!user.data.is_evaluator
) {
router.push({
name: 'ProgrammingExerciseSubmission',
params: { exerciseID: props.exerciseID, submissionID: 'new' },
})
return
}
}
const updateTestCases = (doc: any) => {
if (testCases.value.length === 0) {
testCases.value = doc.test_cases || []
}
}
watch(
() => submission.doc,
(doc) => {
if (doc) {
checkIfUserIsPermitted(doc)
updateTestCases(doc)
updateCode(doc.code)
}
},
{ immediate: true }
)
const loadFalcon = () => {
if (livecodeURL.data) {
falconURL.value = livecodeURL.data
}
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = `${falconURL.value}static/livecode.js`
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
}
const submitCode = async () => {
await runCode()
createSubmission()
}
const runCode = async () => {
if (!exercise.doc?.test_cases?.length) return
testCases.value = []
if (testCaseSection.value) {
testCaseSection.value.scrollIntoView({ behavior: 'smooth' })
}
for (const test_case of exercise.doc.test_cases) {
let result = await execute(test_case.input)
if (error.value) {
errorMessage.value = result
break
} else {
output.value = result
}
let status =
result.trim() === test_case.expected_output.trim() ? 'Passed' : 'Failed'
testCases.value.push({
input: test_case.input,
output: result,
expected_output: test_case.expected_output,
status: status,
})
}
}
const createSubmission = () => {
if (!testCases.value.length) return
let codeToSave = code.value?.replace(boilerplate.value, '') || ''
call('lms.lms.api.create_programming_exercise_submission', {
exercise: props.exerciseID,
submission: props.submissionID,
code: codeToSave,
test_cases: testCases.value,
})
.then((data: any) => {
if (props.submissionID == 'new') {
router.push({
name: 'ProgrammingExerciseSubmission',
params: { exerciseID: props.exerciseID, submissionID: data },
})
fetchSubmission(data)
} else {
fetchSubmission(props.submissionID)
}
toast.success(__('Submission saved!'))
})
.catch((error: any) => {
console.error('Error creating submission:', error)
toast.error(
__('Failed to submit. Please try again. {0}').format({ error })
)
})
}
const execute = (stdin = ''): Promise<string> => {
return new Promise((resolve, reject) => {
let outputChunks: string[] = []
let hasExited = false
let hasError = false
let session = new LiveCodeSession({
base_url: falconURL.value,
runtime: exercise.doc?.language.toLowerCase() || 'python',
code: code.value,
files: [{ filename: 'stdin', contents: stdin }],
onMessage: (msg: any) => {
console.log('msg', msg)
if (msg.msgtype === 'write' && msg.file === 'stdout') {
outputChunks.push(msg.data)
}
if (msg.msgtype === 'write' && msg.file === 'stderr') {
hasError = true
errorMessage.value = msg.data
}
if (msg.msgtype === 'exitstatus') {
hasExited = true
if (msg.exitstatus !== 0) {
error.value = true
} else {
error.value = false
}
resolve(outputChunks.join('').trim())
}
},
})
setTimeout(() => {
if (!hasExited) {
error.value = true
errorMessage.value = 'Execution timed out.'
reject('Execution timed out.')
}
}, 20000)
})
}
const breadcrumbs = computed(() => {
return [
{
label: __('Programming Exercise Submissions'),
route: { name: 'ProgrammingExerciseSubmissions' },
},
{ label: exercise.doc?.title },
]
})
usePageMeta(() => {
return {
title: __('Programming Exercise Submission'),
icon: brand.favicon,
}
})
</script>
<style>
.ProseMirror pre {
background: theme('colors.gray.200');
color: theme('colors.gray.900');
}
</style>

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