Compare commits

...

341 Commits

Author SHA1 Message Date
Jannat Patel
6a0b37a4d4 chore: changed release branch to develop 2024-07-31 12:43:04 +05:30
Jannat Patel
f7fd6916e2 chore: release notes action 2024-07-31 12:15:24 +05:30
Jannat Patel
30e61f4b7c Merge pull request #947 from pateljannat/semantic-release
chore: semantic release action
2024-07-31 11:45:42 +05:30
Jannat Patel
48b37d58d8 chore: semantic release action 2024-07-31 11:10:47 +05:30
Jannat Patel
e96f18df7c Merge pull request #946 from pateljannat/issues-27
fix: quiz shuffle issue
2024-07-30 18:04:26 +05:30
Jannat Patel
7d15527831 fix: quiz shuffle issue 2024-07-30 16:58:09 +05:30
Jannat Patel
794c0e760b Merge pull request #945 from pateljannat/issues-26
fix: eval request issues
2024-07-30 15:26:46 +05:30
Jannat Patel
e46a60d00a chore: linters 2024-07-30 15:05:24 +05:30
Jannat Patel
819aac70fd fix: linters 2024-07-30 14:54:21 +05:30
Jannat Patel
ed7db2d7c5 fix: eval request issues 2024-07-30 14:17:17 +05:30
Jannat Patel
98a56f9117 Merge pull request #936 from pateljannat/image-pasting
fix: batch filters on desk
2024-07-19 12:19:34 +05:30
Jannat Patel
cbc4b8c59d chore: removed unused plugin 2024-07-19 12:07:35 +05:30
Jannat Patel
69d266e018 feat: image pasting 2024-07-19 11:55:36 +05:30
Jannat Patel
4bc3ac1665 fix: batch filters on desk 2024-07-18 21:29:40 +05:30
Jannat Patel
e0de9d70de Merge pull request #933 from pateljannat/issues-25
fix: misc issues
2024-07-17 11:40:35 +05:30
Jannat Patel
493bab8163 fix: multiline tags 2024-07-17 11:29:14 +05:30
Jannat Patel
25a2d82e82 fix: misc issues 2024-07-16 16:11:24 +05:30
Jannat Patel
0183677494 Merge pull request #932 from pateljannat/batch-categories
feat: batch categories filter
2024-07-15 14:45:57 +05:30
Jannat Patel
7ae9244896 feat: batch categories filter 2024-07-15 13:45:38 +05:30
Jannat Patel
15330cb41d Merge pull request #928 from pateljannat/completion-certificate
feat: completion certificate
2024-07-12 20:36:06 +05:30
Jannat Patel
166996d77a chore: removed unnecessary lines 2024-07-12 20:17:42 +05:30
Jannat Patel
4943e0e902 chore: fixed linters 2024-07-12 19:58:52 +05:30
Jannat Patel
1db6a8bfda chore: fixed linters 2024-07-12 19:56:01 +05:30
Jannat Patel
57f43b256a feat: enable certification from course form 2024-07-12 19:46:45 +05:30
Jannat Patel
23b2e8d682 feat: generate certificate from course page 2024-07-12 15:56:50 +05:30
Jannat Patel
6e1d62340f feat: completion certificate 2024-07-11 18:12:15 +05:30
Jannat Patel
63d613a88e chore: fixed linters 2024-07-10 22:58:22 +05:30
Jannat Patel
70a4d16a8a chore: merge conflict 2024-07-10 22:40:40 +05:30
Jannat Patel
9960507318 Merge pull request #924 from pateljannat/reorder-lessons
feat: reorder lessons
2024-07-10 22:00:54 +05:30
Jannat Patel
a84b225247 chore: fixed linters 2024-07-10 21:53:49 +05:30
Jannat Patel
a1f938eaaf chore: removed unnecessary lines 2024-07-10 21:51:00 +05:30
Jannat Patel
f7027e9cfd fix: success message after lesson moved 2024-07-10 21:38:21 +05:30
Jannat Patel
8164526763 fix: misc issues 2024-07-10 16:13:28 +05:30
Jannat Patel
2ecc07ee58 feat: reorder lessons 2024-07-09 22:45:45 +05:30
Jannat Patel
8edfe041c3 Merge pull request #922 from pateljannat/delete-lesson
feat: delete lessons
2024-07-08 19:53:19 +05:30
Jannat Patel
e3e54b0188 feat: delete lessons 2024-07-08 18:15:22 +05:30
Jannat Patel
602d457212 Merge pull request #921 from pateljannat/course-cache-issue
fix: course cache issue
2024-07-08 12:38:02 +05:30
Jannat Patel
8bd4a5448b fix: achievements title issue 2024-07-08 11:18:59 +05:30
Jannat Patel
2257c09228 fix: course cache issue 2024-07-05 19:24:49 +05:30
Jannat Patel
dac8a3ecf2 fix: translations upstream 2024-07-05 16:03:36 +05:30
Jannat Patel
288f85b2f3 fix: indian state validation 2024-07-04 17:35:44 +05:30
Jannat Patel
d5d9e5e6e8 chore: changed translation cron 2024-07-04 17:32:10 +05:30
Jannat Patel
e194f2efea Merge pull request #919 from pateljannat/quiz-instructions
fix: instructions for user input questions
2024-07-04 10:25:00 +05:30
Jannat Patel
686839adc1 fix: instructions for user input questions 2024-07-03 22:41:39 +05:30
Jannat Patel
0c52b5a8ec fix: badge share url 2024-07-02 16:17:28 +05:30
Jannat Patel
f80a139c93 Merge pull request #913 from pateljannat/translations
feat: translations via gettext
2024-07-02 14:54:01 +05:30
Jannat Patel
eeadd6910e fix: changed cron to wednesday 2024-07-02 14:43:08 +05:30
Jannat Patel
eed16d9604 feat: translations via gettext 2024-07-01 11:15:12 +05:30
Jannat Patel
3745db6da4 Merge pull request #905 from pateljannat/search-course
feat: search courses
2024-06-28 19:05:34 +05:30
Jannat Patel
4ef694a2ed fix: search cache issue 2024-06-28 18:51:47 +05:30
Jannat Patel
279bb89ca9 feat: search courses 2024-06-28 15:58:35 +05:30
Jannat Patel
0bc5714392 Merge pull request #902 from pateljannat/quiz-limit
feat: limit questions in quiz
2024-06-28 12:48:10 +05:30
Jannat Patel
764f358708 feat: limit questions in quiz 2024-06-28 12:21:07 +05:30
Jannat Patel
bf43fd5079 Merge pull request #901 from pateljannat/issues-24
fix: lesson editor menu position
2024-06-27 21:58:39 +05:30
Jannat Patel
6adc62c72a fix: lesson editor menu position 2024-06-27 21:50:02 +05:30
Jannat Patel
2cf894df59 Merge pull request #900 from pateljannat/correct-workspace-links
fix: corrected workspace links
2024-06-27 21:29:08 +05:30
Jannat Patel
72053dbf56 fix: corrected workspace links 2024-06-27 21:20:14 +05:30
Jannat Patel
0cd50ff1b6 Merge pull request #898 from pateljannat/google-drive
feat: more embed sources for lesson components
2024-06-27 14:28:48 +05:30
Jannat Patel
d3f443014c feat: more embed sources for lesson components 2024-06-27 12:30:48 +05:30
Jannat Patel
ecd56609a0 Merge pull request #895 from pateljannat/issues-23
fix: minor UI issues
2024-06-26 15:01:06 +05:30
Jannat Patel
1af10b7f96 chore: removed unused imports 2024-06-26 14:29:29 +05:30
Jannat Patel
55170361c1 fix: minor UI issues 2024-06-26 14:25:16 +05:30
Jannat Patel
daa42f146d Merge pull request #894 from pateljannat/issues-22
fix: batch ui
2024-06-26 13:52:30 +05:30
Jannat Patel
67ebd30836 fix: batch ui 2024-06-26 13:10:20 +05:30
Jannat Patel
ac282cebfb Merge pull request #878 from MuhammadKazimSadiq/recent-courses-web-template
Recent courses web template
2024-06-26 11:13:38 +05:30
Jannat Patel
4aea074041 Merge pull request #888 from pateljannat/batch-instructors
feat: instructors in courses and batches
2024-06-26 10:42:01 +05:30
Jannat Patel
888ea5a911 test: type instructor 2024-06-26 10:32:42 +05:30
Jannat Patel
f02b9c09e6 chore: fixed merge conflicts 2024-06-26 09:34:09 +05:30
Jannat Patel
5e91553190 test: instructor when creating course 2024-06-26 09:33:03 +05:30
Md Hussain Nagaria
326c77cdb9 fix: clip progress bar width to 100% (#892)
* fix: course progress bar should not go beyond 100% width

* refactor: progress bar component should include outer body div
2024-06-26 00:29:55 +05:30
Muhammad Kazim
e10af413b2 Merge branch 'develop' into recent-courses-web-template 2024-06-25 19:44:08 +03:30
Md Hussain Nagaria
d4a15ade98 fix: seats left condition (#889) 2024-06-25 21:28:32 +05:30
Jannat Patel
b18a3cb5e1 test: instructor for courses 2024-06-25 19:56:50 +05:30
Jannat Patel
96028c9f42 feat: instructors in courses and batches 2024-06-25 18:43:28 +05:30
Jannat Patel
8625ac048a Merge pull request #877 from nikkothari22/fix-seats-badge
fix: show "Seats Left" instead of "Seat Left" if more than 1 seat is available
2024-06-24 14:35:11 +05:30
Jannat Patel
30934f9eba Merge pull request #884 from pateljannat/issues-21
fix: job page layout
2024-06-24 14:31:22 +05:30
Jannat Patel
8349f47cbe Merge branch 'develop' into fix-seats-badge 2024-06-24 14:30:24 +05:30
Muhammad Kazim
9d3d93443f fix linting issues with pre-commit 2024-06-22 10:46:06 +00:00
Muhammad Kazim
4b6d1c296c Merge branch 'frappe:develop' into recent-courses-web-template 2024-06-22 13:28:51 +03:30
Jannat Patel
0a3a48759f fix: deleted fixture json 2024-06-21 19:23:18 +05:30
Jannat Patel
407bea4ab9 fix: removed badge fixture 2024-06-21 19:17:23 +05:30
Jannat Patel
3ba34f36eb fix: removed meta from eval web form as its not needed 2024-06-21 19:03:22 +05:30
Jannat Patel
8d1c03d4c1 chore: removed unused imports 2024-06-21 18:52:12 +05:30
Jannat Patel
ed739b25e2 fix: job page layout 2024-06-21 18:41:10 +05:30
Nikhil Kothari
807c9b2225 fix: use single span instead of multiple badges 2024-06-21 17:17:36 +05:30
Jannat Patel
7d3dc8df90 Merge pull request #883 from pateljannat/batch-timezone
feat: timezone in batches
2024-06-21 14:23:59 +05:30
Jannat Patel
aafb8948d2 chore: resolved conflicts 2024-06-21 14:05:03 +05:30
Jannat Patel
4f2dd7654c fix: notification template for timezone 2024-06-21 13:17:54 +05:30
Jannat Patel
9c2bebb3d9 feat: timezone in batches 2024-06-21 13:08:58 +05:30
Jannat Patel
e93e82b56c Merge pull request #882 from pateljannat/video-player
feat: video and audio player
2024-06-21 11:44:08 +05:30
Jannat Patel
f783b981e5 feat: added support to embed aparat 2024-06-21 11:38:30 +05:30
Jannat Patel
b5a904354a test: fix lesson content 2024-06-21 11:24:28 +05:30
Jannat Patel
ed7c30057c test: increase wait time when lesson is opened 2024-06-21 10:36:26 +05:30
Jannat Patel
05e8513ad1 feat: video player 2024-06-20 16:46:50 +05:30
Muhammad Kazim
775f8db31e Merge branch 'frappe:develop' into recent-courses-web-template 2024-06-17 00:57:16 +03:30
Muhammad Kazim
4b6d3fe968 web template for recently published courses 2024-06-16 21:26:16 +00:00
Nikhil Kothari
e397295b5e fix: use else-if 2024-06-14 16:30:33 +05:30
Nikhil Kothari
1d16c46003 fix: show "30 seats left" instead of "seat left" in batches 2024-06-14 16:29:58 +05:30
Jannat Patel
3ba8805413 Merge branch 'develop' of https://github.com/frappe/lms into video-player 2024-06-14 10:28:22 +05:30
Jannat Patel
63f4dc0caa Merge pull request #876 from pateljannat/issues-20
fix: misc issues
2024-06-14 10:22:16 +05:30
Jannat Patel
965bdd7890 fix: hide discussions is lesson has quiz 2024-06-14 09:48:22 +05:30
Jannat Patel
a99c41a07b fix: misc changes 2024-06-13 15:34:57 +05:30
Jannat Patel
81feee887c Merge pull request #870 from pateljannat/issues-19
fix: better validation message on course creation
2024-06-12 12:11:47 +05:30
Jannat Patel
77433ebb7c fix: better validation message on course creation 2024-06-12 11:44:14 +05:30
Jannat Patel
d5614322c5 Merge pull request #869 from pateljannat/issues-18
fix: event meeting generation
2024-06-12 10:54:16 +05:30
Jannat Patel
479ff037c6 fix: convert eval string to dict 2024-06-12 10:42:47 +05:30
Jannat Patel
8a74f495e7 fix: event meeting generation 2024-06-12 10:32:57 +05:30
Jannat Patel
231f2cbc14 feat: video player 2024-06-12 10:27:33 +05:30
Jannat Patel
1f466482f8 Merge pull request #867 from pateljannat/issues-17
fix: perf issues
2024-06-07 11:52:41 +05:30
Jannat Patel
103ecef9f4 fix: misc issues 2024-06-07 11:37:46 +05:30
Jannat Patel
2744002390 Merge pull request #866 from pateljannat/issues-16
fix: misc issues
2024-06-06 22:04:47 +05:30
Jannat Patel
fd26d2bcd1 fix: misc issues 2024-06-06 21:57:24 +05:30
Md Hussain Nagaria
a55da8149a fix: forward resource submit promise (#861) 2024-06-06 18:23:14 +05:30
Jannat Patel
631f69bd75 Merge pull request #860 from pateljannat/issues-15
fix: sold out batch issue
2024-06-06 13:07:13 +05:30
Jannat Patel
621556263b fix: sold out batch issue 2024-06-06 13:00:08 +05:30
Jannat Patel
6fdd1a5f09 fix: job application count 2024-06-05 15:58:08 +05:30
Jannat Patel
a2b3bc8c1f Merge pull request #856 from pateljannat/jobs-applied
feat: job application count
2024-06-05 13:56:57 +05:30
Jannat Patel
d932baf896 feat: job application count 2024-06-05 10:53:31 +05:30
Jannat Patel
a9b469d3bf Merge pull request #849 from pateljannat/sidebar-edit
feat: configure sidebar items
2024-06-04 09:43:35 +05:30
Jannat Patel
e2bd9401a6 fix: removed unused imports 2024-06-04 09:22:16 +05:30
Jannat Patel
c6ada95b9d feat: edit and delete sidebar item 2024-06-03 20:28:25 +05:30
Jannat Patel
330a2f632a feat: edit and delete sidebar item 2024-06-03 20:27:38 +05:30
Jannat Patel
bf6a7a85a7 feat: web pages for sidebar 2024-05-31 21:26:11 +05:30
Jannat Patel
e609153f4f Merge branch 'develop' of https://github.com/frappe/lms into sidebar-edit 2024-05-30 12:31:50 +05:30
Jannat Patel
bedb1dc8d3 Merge pull request #852 from pateljannat/issues-14
fix: evaluation related issues
2024-05-30 12:24:32 +05:30
Jannat Patel
7f2821f639 fix: evaluation related issues 2024-05-30 12:16:01 +05:30
Jannat Patel
844f4b5e8d feat: new web page table 2024-05-30 11:01:30 +05:30
Jannat Patel
1e69ff7de8 feat: configure sidebar items 2024-05-29 17:16:09 +05:30
Jannat Patel
e6d58721f0 Merge pull request #846 from pateljannat/share-badge
feat: share badge on linkedin
2024-05-28 12:37:51 +05:30
Jannat Patel
7c077ace95 feat: share badge on social media 2024-05-28 12:05:16 +05:30
Jannat Patel
d03dd3d20d Merge branch 'develop' of https://github.com/frappe/lms into share-badge 2024-05-27 17:36:31 +05:30
Jannat Patel
a780668aac Merge pull request #841 from pateljannat/featured-courses
feat: featured courses
2024-05-27 15:51:31 +05:30
Jannat Patel
c0998ca8b3 feat: share badge on linkedin 2024-05-27 15:31:53 +05:30
Jannat Patel
b7dd488886 feat: featured courses 2024-05-27 15:30:15 +05:30
Jannat Patel
cb9125632a Merge pull request #837 from pateljannat/issues-13
fix: minor issues
2024-05-24 17:08:03 +05:30
Jannat Patel
9a776dabed fix: minor issues 2024-05-24 17:00:14 +05:30
Jannat Patel
180c8941a4 Merge pull request #787 from Bowrna/patch-2
Update README.md
2024-05-24 15:34:38 +05:30
Jannat Patel
f6b83d3518 Merge branch 'develop' into patch-2 2024-05-24 15:25:53 +05:30
Jannat Patel
97712dbdc0 Merge pull request #711 from ph4ni/main
Added batch start and end date validation
2024-05-24 15:19:58 +05:30
Jannat Patel
febf38a47a Merge pull request #739 from ahmadRagheb/patch-3
Update edit.html
2024-05-24 15:18:54 +05:30
Jannat Patel
753ae01efc Merge pull request #836 from pateljannat/notifications
feat: notifications
2024-05-24 15:13:39 +05:30
Jannat Patel
cef638e37a chore: removed unnecessary code 2024-05-24 15:08:18 +05:30
Jannat Patel
850069d380 feat: batch notifications 2024-05-24 13:08:02 +05:30
Jannat Patel
a748e2c2db feat: notification on mentions 2024-05-23 21:25:22 +05:30
Jannat Patel
f38aebbc9c feat: notifications 2024-05-22 20:40:02 +05:30
Jannat Patel
8e1b871f87 Merge pull request #824 from pateljannat/issues-12
fix: edit permission and other issues
2024-05-17 18:31:09 +05:30
Jannat Patel
76ea4fc1ae fix: edit permission and other issues 2024-05-17 18:03:04 +05:30
Jannat Patel
36c7c10d94 Merge pull request #821 from pateljannat/issues-11
fix: batch related issues
2024-05-16 13:15:24 +05:30
Jannat Patel
5148fcf25b fix: batch related issues 2024-05-16 12:53:14 +05:30
Jannat Patel
d00d152fdc Merge pull request #816 from pateljannat/ui-tests
test: course creation flow
2024-05-16 11:46:29 +05:30
Jannat Patel
7123736707 chore: removed unnecessary files 2024-05-16 10:56:42 +05:30
Jannat Patel
0ad685c262 text: removed user check 2024-05-15 18:10:12 +05:30
Jannat Patel
f2bef08568 ci: revert linter changes 2024-05-15 18:06:35 +05:30
Jannat Patel
7651eb5f97 test: fix discussions and asset testing 2024-05-15 17:56:11 +05:30
Jannat Patel
a0fc1b0a9e test: course creation flow 2024-05-15 14:55:54 +05:30
Jannat Patel
a62fd757d4 test: course creation flow 2024-05-15 14:55:28 +05:30
Jannat Patel
0094809273 Merge pull request #795 from pateljannat/badges
feat: badges
2024-05-14 19:46:29 +05:30
Jannat Patel
2ea5858716 fix: reverted unnecesary custom fields 2024-05-14 15:14:41 +05:30
Jannat Patel
753e62b441 feat: auto assign badges 2024-05-14 14:53:34 +05:30
Jannat Patel
68a1d1e436 fix: progress update 2024-05-13 16:50:52 +05:30
Jannat Patel
0d89f51d78 chore: merge conflicts 2024-05-13 11:16:13 +05:30
Jannat Patel
9670dfa916 Merge pull request #810 from pateljannat/issues-10
fix: mobile and lesson issues
2024-05-13 11:14:00 +05:30
Jannat Patel
2a19fbc3d2 fix: linter github action 2024-05-13 10:39:03 +05:30
Jannat Patel
e98d7d29f7 fix: removed permission check for linters 2024-05-13 10:31:09 +05:30
Jannat Patel
3c5918d485 fix: added branches to linters github action 2024-05-13 10:23:33 +05:30
Jannat Patel
65e9d164f5 fix: linter action 2024-05-13 10:22:11 +05:30
Jannat Patel
9d0b120cde fix: removed unnecessary commits 2024-05-10 19:04:13 +05:30
Jannat Patel
10ed37ec67 fix: mobile and lesson issues 2024-05-10 18:41:39 +05:30
Jannat Patel
528ab8f796 Merge pull request #808 from pateljannat/issues-9
fix: course issues
2024-05-09 16:45:43 +05:30
Jannat Patel
30973eb78d fix: course issues 2024-05-09 16:35:04 +05:30
Jannat Patel
2be7645c0c fix: course issues 2024-05-09 16:34:54 +05:30
Jannat Patel
0075c44918 fix: achievements position 2024-05-09 12:25:48 +05:30
Jannat Patel
4a321440d9 Merge pull request #803 from pateljannat/issues-8
fix: certified participants page mobile layout
2024-05-08 11:15:38 +05:30
Jannat Patel
b4cc0c6807 fix: certified participants page mobile layout 2024-05-07 19:03:27 +05:30
Jannat Patel
98c748359a Merge pull request #802 from pateljannat/issues-7
fix: misc issues
2024-05-06 16:44:28 +05:30
Jannat Patel
5c51e01c78 fix: linter 2024-05-06 16:24:57 +05:30
Jannat Patel
650f81c22b fix: misc issues 2024-05-06 16:20:47 +05:30
Jannat Patel
3478f278ff fix: progress 2024-05-06 14:24:19 +05:30
Jannat Patel
d53123cf07 Merge pull request #792 from dj12djdjs/fix-existing-role
fix: check if role exists before renaming
2024-05-03 16:01:19 +05:30
Jannat Patel
cf5a088f5e fix: disable self learning 2024-05-03 11:38:41 +05:30
Jannat Patel
7d937eb024 Merge pull request #794 from arunmathaisk/develop
feat: CertifiedParticipants.vue page is now searchable
2024-05-03 10:56:18 +05:30
Arun Mathai SK
4edaad53a1 chore : change to Lucide icon, lint issues 2024-05-02 15:07:08 +00:00
Jannat Patel
8a2991c4fb fix: multiple badges 2024-05-02 19:43:32 +05:30
Arun Mathai SK
0c9fdc6534 feat: CertifiedParticipants.vue page is now searchable 2024-05-02 13:00:46 +00:00
Devin Slauenwhite
889bc2b1c7 fix: check if role exists before renaming 2024-04-30 20:27:29 -04:00
Jannat Patel
7355be2a8b feat: badges 2024-04-30 19:25:07 +05:30
Jannat Patel
0f64da69c0 Merge pull request #791 from pateljannat/issues-5
fix: misc fixes
2024-04-30 11:12:55 +05:30
Jannat Patel
d9ad642a31 fix: misc fixes 2024-04-30 10:52:35 +05:30
Md Hussain Nagaria
180140c13f fix: attempts should be reloaded when quiz data changes (#790) 2024-04-29 20:03:11 +05:30
Jannat Patel
e7d7cffbc5 fix: unavailability validations 2024-04-29 13:39:58 +05:30
Jannat Patel
29ccbddea8 Merge pull request #788 from pateljannat/issues-4
fix: seo link redirections and guest certificate
2024-04-26 16:31:53 +05:30
Jannat Patel
944020ca6e fix: seo link redirections and guest certificate 2024-04-26 16:20:09 +05:30
Bowrna
4bc07100f5 Update README.md
After the docker is up, we may need to log in to the app for first time. Those credentials were hidden inside the file and keeping it in README.md may be useful for new contributors like me. Also the docker-compose yaml, comes bundled with frappe bench therefore the following guidelines on installing the frappe bench may be irrelevant.
2024-04-26 14:26:52 +05:30
Jannat Patel
7dec8f019f Merge pull request #783 from pateljannat/lesson-issues-3
fix: misc lesson issues
2024-04-26 10:32:21 +05:30
Jannat Patel
1a2cb0fc3c fix: misc lesson issues 2024-04-25 16:41:56 +05:30
Jannat Patel
5dc3b62b94 Merge pull request #782 from pateljannat/quiz-shuffle
feat: quiz shuffle
2024-04-25 12:37:03 +05:30
Jannat Patel
4a4c8b4e7a fix: explanation issue 2024-04-25 12:27:00 +05:30
Jannat Patel
c3390b9005 feat: quiz shuffle 2024-04-24 12:51:01 +05:30
Jannat Patel
804fc8e391 fix: quiz question rendering 2024-04-24 12:31:11 +05:30
Jannat Patel
5b5d779f25 fix: certified participants query 2024-04-24 11:27:50 +05:30
Jannat Patel
3fcf037db2 Merge pull request #781 from pateljannat/certified-users
feat: certified participants
2024-04-23 17:58:50 +05:30
Jannat Patel
324ede5523 fix: converted sql to qb 2024-04-23 17:52:21 +05:30
Jannat Patel
e6e8718bb4 feat: certified participants 2024-04-23 17:29:16 +05:30
Jannat Patel
93e42fbd86 fix: zoom settings 2024-04-22 21:10:19 +05:30
Jannat Patel
8a6adae89c fix: lesson content rendering 2024-04-22 20:51:09 +05:30
Jannat Patel
e3ad0baeb7 fix: batch details meta 2024-04-22 17:41:42 +05:30
Jannat Patel
953eb74235 fix: lesson duplicate video rendering 2024-04-22 17:18:53 +05:30
Jannat Patel
b3c76e311c fix: unavailability validations 2024-04-22 16:56:07 +05:30
Jannat Patel
f41eb30f3c Merge branch 'develop' of https://github.com/frappe/lms into develop 2024-04-22 16:50:18 +05:30
Jannat Patel
dbe236f75e fix: edit course when no tags added 2024-04-22 16:50:10 +05:30
Jannat Patel
7d4a9eaf45 Merge pull request #779 from pateljannat/lesson-issue-1
fix: misc lesson fixes
2024-04-22 15:06:34 +05:30
Jannat Patel
4f7c3f14df fix: batch lists 2024-04-22 14:38:46 +05:30
Jannat Patel
f15fdcc42e feat: code editor 2024-04-19 14:59:40 +05:30
Jannat Patel
44b36599c3 fix: profile routes 2024-04-18 20:27:15 +05:30
Jannat Patel
86713db75e fix: misc lesson fixes 2024-04-17 16:46:20 +05:30
Jannat Patel
d2491b81c0 Merge pull request #778 from pateljannat/new-bill-job
feat: new tab and other misc fixes
2024-04-17 10:43:42 +05:30
Jannat Patel
b98f6369ae feat: new tab and other misc fixes 2024-04-17 10:36:48 +05:30
Md Hussain Nagaria
a2a210d82b Merge pull request #777 from frappe/fix-batch-date-range
fix: batch date range
2024-04-16 22:04:00 +05:30
Hussain Nagaria
5b120ad248 refactor: take format as optional argument 2024-04-16 22:02:56 +05:30
Hussain Nagaria
4ec57349f8 refactor: extract out component for date range 2024-04-16 21:39:19 +05:30
Hussain Nagaria
f48f437075 fix: handle same start and end dates case for batch
* also minor refactor
2024-04-16 21:24:53 +05:30
Jannat Patel
255990b022 Merge pull request #774 from pateljannat/profile
feat: profile page
2024-04-16 17:37:37 +05:30
Jannat Patel
ac44c59e50 fix: unavailability validaions 2024-04-16 17:28:45 +05:30
Jannat Patel
9252920a79 feat: google calendar integration 2024-04-16 16:56:18 +05:30
Jannat Patel
719e471678 feat: evaluator unavailability 2024-04-16 11:08:30 +05:30
Jannat Patel
39bc141133 Merge branch 'develop' of https://github.com/frappe/lms into profile 2024-04-15 13:55:35 +05:30
Jannat Patel
78698cfcbe Merge pull request #773 from pateljannat/youtube-issues
fix: lesson youtube video issue
2024-04-15 13:33:03 +05:30
Jannat Patel
64c02f14a9 fix: removed quiz_id from the lesson content 2024-04-15 13:29:50 +05:30
Jannat Patel
2f68fc0d6e fix: quiz rerender issue 2024-04-15 13:27:38 +05:30
Jannat Patel
16570469e6 fix: lesson youtube video issue 2024-04-15 12:35:27 +05:30
Jannat Patel
f2c14d09d4 feat: profile settings for roles 2024-04-15 12:10:11 +05:30
Jannat Patel
7e81b9d45d Merge branch 'develop' of https://github.com/frappe/lms into profile 2024-04-15 10:06:58 +05:30
Md Hussain Nagaria
a62b754d28 fix: empty the container before editorjs init (#771) 2024-04-12 13:52:43 +05:30
Jannat Patel
93859d6635 Merge pull request #769 from pateljannat/fixes-lesson
fix: check site conf before sending email
2024-04-10 21:23:15 +05:30
Jannat Patel
20fab8dbd3 fix: check site conf before sending email 2024-04-10 19:38:30 +05:30
Jannat Patel
e56c8cc5f8 feat: profile page 2024-04-10 11:53:28 +05:30
Jannat Patel
13d0621881 feat: profile page 2024-04-10 11:53:00 +05:30
Md Hussain Nagaria
13536aac51 fix: primary page title (#767) 2024-04-09 15:33:10 +05:30
Jannat Patel
ccb8721674 fix: evaluation modal 2024-04-06 11:19:49 +05:30
Jannat Patel
fbe1423edd fix: meta for batches 2024-04-06 11:17:25 +05:30
Jannat Patel
d2713d7824 Merge pull request #763 from pateljannat/slots-message
fix: no slots message
2024-04-06 11:03:26 +05:30
Jannat Patel
f24a15b4f8 fix: no slots message 2024-04-06 10:55:50 +05:30
Jannat Patel
bf74868bd4 Merge pull request #762 from pateljannat/assignments
feat: assignments in batches
2024-04-05 22:51:39 +05:30
Jannat Patel
6d75b8a3a2 feat: assignments in batches 2024-04-05 21:51:18 +05:30
Jannat Patel
055f917c61 Merge pull request #761 from pateljannat/rewrite-fixes
fix: mobile layout and certification link
2024-04-05 08:59:38 +05:30
Jannat Patel
d892a28069 fix: mobile layout and certification link 2024-04-05 08:56:18 +05:30
Jannat Patel
838bc1fac2 Merge pull request #760 from pateljannat/fix-templates
fix: web templates
2024-04-04 15:41:14 +05:30
Jannat Patel
a98d36513b fix: web templates 2024-04-04 15:01:22 +05:30
Jannat Patel
2cfa6771ac fix: lesson page issue 2024-04-04 11:24:44 +05:30
Jannat Patel
4960c47377 Merge pull request #758 from pateljannat/branding
fix: branding, course, lesson and certificate creation
2024-04-04 10:31:13 +05:30
Jannat Patel
a46306720b fix: branding, course, lesson and certificate creation 2024-04-03 22:42:59 +05:30
Jannat Patel
efcdba3a29 Merge pull request #713 from pateljannat/lms-frappe-ui
feat: LMS rewrite in Frappe UI
2024-04-03 12:22:03 +05:30
Jannat Patel
e7263d0566 style: removed print statement 2024-04-03 12:14:51 +05:30
Jannat Patel
5de7f5e283 fix: progress 2024-04-03 12:11:34 +05:30
Jannat Patel
c7bdf68bc6 bump: frappe-ui 2024-04-02 14:07:52 +05:30
Jannat Patel
998ff51c58 chore: removed print statements 2024-04-01 22:34:59 +05:30
Jannat Patel
4eb9d84d8b chore: fixed semgrep 2024-04-01 22:29:24 +05:30
Jannat Patel
5f000d8017 style: linters for vue files 2024-04-01 22:01:55 +05:30
Jannat Patel
0cf9ad5228 chore: resolved conflicts 2024-04-01 21:54:01 +05:30
Jannat Patel
71a526e7aa fix: dummy text to make content words greater than 250 2024-04-01 18:38:31 +05:30
Jannat Patel
c5d9adb7fd fix: added paragraph for description and a link for seo 2024-04-01 17:47:34 +05:30
Jannat Patel
05fdf163a9 fix: certificate website link 2024-04-01 17:27:12 +05:30
Jannat Patel
de0a983033 fix: replaced h2 with h1 for flash text 2024-03-29 22:44:17 +05:30
Jannat Patel
44ee9b644f fix: flash text for seo 2024-03-29 22:29:24 +05:30
Jannat Patel
bd116c3e7b feat: meta for batches 2024-03-29 14:48:46 +05:30
Jannat Patel
02e8a97f85 chore: post install scripts 2024-03-28 17:20:39 +05:30
Jannat Patel
3525e4c90b feat: meta tags 2024-03-28 16:20:53 +05:30
Jannat Patel
e6d3819092 feat: mobile responsive 2024-03-28 11:30:59 +05:30
Jannat Patel
f15862cef4 fix: router 2024-03-19 12:51:11 +05:30
Jannat Patel
86748b301d feat: page titles 2024-03-19 12:04:58 +05:30
Jannat Patel
cc07dd849c feat: job creation 2024-03-18 16:55:36 +05:30
Jannat Patel
63bcbb6506 feat: batch creation 2024-03-15 21:54:02 +05:30
Jannat Patel
83a1b03bb7 feat: quiz plugin in lesson 2024-03-11 09:38:27 +05:30
Jannat Patel
2126b4f657 fix: lesson editing 2024-03-07 10:06:32 +05:30
Jannat Patel
0ce7c74778 feat: lesson creation 2024-03-05 23:07:58 +05:30
Jannat Patel
b9f6a23412 chore: build files 2024-03-04 22:12:21 +05:30
Jannat Patel
9ae96bd1fa feat: chapter creation 2024-03-04 22:10:51 +05:30
Jannat Patel
e863abe37c fix: structure of ourline creation 2024-03-01 13:50:00 +05:30
Jannat Patel
80e9984db0 feat: course creation resources 2024-03-01 10:57:31 +05:30
Jannat Patel
8f504a8043 fix: course creation form validations 2024-02-28 23:42:17 +05:30
Jannat Patel
60a917e60c feat: course creation page structure 2024-02-27 20:41:02 +05:30
Jannat Patel
5317aa8fb5 feat: job application 2024-02-21 12:08:05 +05:30
Jannat Patel
36c4e2f4dc feat: job application modal 2024-02-19 16:54:07 +05:30
Md Hussain Nagaria
8e7c1da7af Merge branch 'main' into main 2024-02-12 04:46:28 +03:00
ahmadRagheb
4f1f7c3fc0 Update edit.html
fix typo
2024-02-08 00:46:54 +02:00
Jannat Patel
084eeba2ed Merge branch 'main' of https://github.com/frappe/lms into lms-frappe-ui 2024-02-07 12:25:37 +05:30
Jannat Patel
6401497422 feat: job details 2024-02-07 10:57:05 +05:30
Jannat Patel
684601f31b Merge branch 'main' of https://github.com/frappe/lms into lms-frappe-ui 2024-02-07 10:55:50 +05:30
Jannat Patel
d7d222842b feat: statistics graphs 2024-02-06 22:39:08 +05:30
Jannat Patel
f59f6c617a fix: cleanup ui 2024-02-05 17:36:55 +05:30
Jannat Patel
af48ccfb57 fix: cleanup ui 2024-02-05 17:35:46 +05:30
Jannat Patel
4b9d3bd996 fix: minor ui 2024-02-01 15:03:50 +05:30
Jannat Patel
53eb95612c fix: review button 2024-02-01 10:48:05 +05:30
Jannat Patel
8f317d2f44 fix: ui issues 2024-01-25 15:24:52 +05:30
Jannat Patel
f4e581f6cb fix: multicurrency on course cards 2024-01-23 17:03:15 +05:30
Jannat Patel
9671c4d63f feat: batch billing 2024-01-23 15:33:31 +05:30
Jannat Patel
b07940951c feat: billing page 2024-01-19 22:48:38 +05:30
Jannat Patel
1f18ef4362 feat: discussions in batches 2024-01-19 17:44:47 +05:30
Jannat Patel
bf57a19e2c Merge branch 'main' of https://github.com/frappe/lms into lms-frappe-ui 2024-01-18 14:44:20 +05:30
Jannat Patel
0845a6e2a3 feat: statistics page numbers 2024-01-17 10:35:34 +05:30
Jannat Patel
041bae16e0 feat: discussions new topic 2024-01-16 20:49:07 +05:30
Jannat Patel
3313db844c feat: discussions edit and delete 2024-01-16 17:24:35 +05:30
Jannat Patel
3a5977a718 feat: discussions 2024-01-15 23:26:31 +05:30
Jannat Patel
bcee74ce77 feat: batch announcements 2024-01-12 21:48:42 +05:30
Jannat Patel
1a6a119f35 feat: students and assessment tab in dashboard 2024-01-10 21:36:02 +05:30
Jannat Patel
09ae61492f feat: upcoming evals 2024-01-08 16:17:50 +05:30
Jannat Patel
3a33f047f5 feat: batch details 2024-01-05 18:22:03 +05:30
Jannat Patel
10cdd712d2 feat: batch card price and seat count 2024-01-02 12:39:28 +05:30
Jannat Patel
21959eef7b feat: quiz submission history table 2024-01-02 11:00:12 +05:30
Jannat Patel
41c3522285 feat: quiz submission 2024-01-01 23:26:53 +05:30
Jannat Patel
7087fde686 feat: quiz base 2023-12-28 11:59:44 +05:30
Sai Phanindra
a6ae5e0675 Update lms_batch.py 2023-12-27 11:31:17 +05:30
Sai Phanindra
cbd5ae9969 Update lms_batch.py 2023-12-27 11:29:51 +05:30
Sai Phanindra
7389d080b6 Update lms_batch.py 2023-12-27 11:28:48 +05:30
Sai Phanindra
8defd664c5 Added batch start and end date validation 2023-12-24 00:06:56 +05:30
Jannat Patel
e1d61c9eb9 feat: review submission 2023-12-21 17:25:08 +05:30
Jannat Patel
afcb15148f chore: merged conflicts 2023-12-21 14:59:30 +05:30
Jannat Patel
5adb36deaf Merge branch 'main' of https://github.com/frappe/lms into lms-frappe-ui 2023-12-21 14:05:59 +05:30
Jannat Patel
4065b1b8cc feat: review modal 2023-12-21 09:56:15 +05:30
Jannat Patel
eb3afbbad1 feat: rating component 2023-12-20 10:35:12 +05:30
Jannat Patel
fbe219a888 feat: lesson pagination 2023-12-18 19:17:44 +05:30
Jannat Patel
5928b8e5f9 feat: lesson pagination 2023-12-18 19:17:17 +05:30
Jannat Patel
372425bed2 Merge branch 'main' of https://github.com/frappe/lms into lms-frappe-ui 2023-12-18 11:31:35 +05:30
Jannat Patel
d2922fd361 feat: lesson page 2023-12-15 23:39:15 +05:30
Jannat Patel
e7b6001e5f fix: logout issue 2023-12-14 14:32:50 +05:30
Jannat Patel
4053984ca2 fix: courses cahce 2023-12-13 11:57:25 +05:30
Jannat Patel
a1e06bf316 fix: courses cahce 2023-12-13 11:57:18 +05:30
Jannat Patel
c50f2147fd feat: course details page design 2023-12-13 10:33:34 +05:30
Jannat Patel
d4671fb888 Merge branch 'main' of https://github.com/frappe/lms into lms-frappe-ui 2023-12-12 14:58:45 +05:30
Jannat Patel
77cda10419 feat: course details page 2023-12-12 10:19:52 +05:30
Jannat Patel
6de879cd2a fix: translations 2023-12-08 19:12:59 +05:30
Jannat Patel
25f24b98c6 feat: fetch translations 2023-12-08 15:04:41 +05:30
Jannat Patel
11079dae00 feat: translations 2023-12-07 11:38:12 +05:30
Jannat Patel
d00da31f84 feat: course list 2023-12-05 22:39:00 +05:30
Jannat Patel
644fb698d8 chore: fixed config 2023-11-30 16:03:06 +05:30
Jannat Patel
92edb3a1bf Merge branch 'main' of https://github.com/frappe/lms into lms-frappe-ui 2023-11-30 12:15:39 +05:30
Jannat Patel
0e2feac81e fix: frappe-ui setup 2023-11-29 22:28:38 +05:30
Jannat Patel
63bcf15900 feat: course cards 2023-11-29 12:05:40 +05:30
Jannat Patel
25bcd10e93 Merge branch 'main' of https://github.com/frappe/lms into lms-frappe-ui 2023-11-28 11:20:52 +05:30
Jannat Patel
0a784766b4 Merge branch 'main' of https://github.com/frappe/lms into lms-frappe-ui 2023-11-09 16:16:08 +05:30
Jannat Patel
7678b89995 feat: setup frappe ui 2023-08-30 22:45:56 +05:30
369 changed files with 40019 additions and 12554 deletions

40
.github/helper/update_pot_file.sh vendored Normal file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
set -e
cd ~ || exit
echo "Setting Up Bench..."
pip install frappe-bench
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch "${BASE_BRANCH}"
cd ./frappe-bench || exit
echo "Get LMS..."
bench get-app --skip-assets lms "${GITHUB_WORKSPACE}"
echo "Generating POT file..."
bench generate-pot-file --app lms
cd ./apps/lms || exit
echo "Configuring git user..."
git config user.email "developers@erpnext.com"
git config user.name "frappe-pr-bot"
echo "Setting the correct git remote..."
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
git remote add upstream https://github.com/frappe/lms.git
echo "Creating a new branch..."
isodate=$(date -u +"%Y-%m-%d")
branch_name="pot_${BASE_BRANCH}_${isodate}"
git checkout -b "${branch_name}"
echo "Commiting changes..."
git add lms/locale/main.pot
git commit -m "chore: update POT file"
gh auth setup-git
git push -u upstream "${branch_name}"
echo "Creating a PR..."
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/lms

34
.github/workflows/generate-pot-file.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Regenerate POT file (translatable strings)
on:
schedule:
- cron: "00 16 * * 5"
workflow_dispatch:
jobs:
regeneratee-pot-file:
name: Release
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branch: ["develop"]
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ matrix.branch }}
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Run script to update POT file
run: |
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
BASE_BRANCH: ${{ matrix.branch }}

View File

@@ -30,4 +30,4 @@ jobs:
run: pip install semgrep
- name: Run Semgrep rules
run: semgrep ci --config ./frappe-semgrep-rules/rules
run: semgrep ci --config ./frappe-semgrep-rules/rules

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

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

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

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

4
.gitignore vendored
View File

@@ -9,4 +9,6 @@ __pycache__/
*.py[cod]
*$py.class
node_modules
package-lock.json
package-lock.json
lms/public/frontend
lms/www/lms.html

View File

@@ -32,7 +32,7 @@ repos:
rev: v2.7.1
hooks:
- id: prettier
types_or: [javascript]
types_or: [javascript, vue]
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(

21
.releaserc Normal file
View File

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

View File

@@ -75,7 +75,13 @@ cd apps/lms/docker
docker-compose up
```
Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should show up.
Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should appear.
You'll have to go through the setup wizard to set up the website the first time you access it. Log in using the following credentials to complete the setup wizard.
```
Username: Administrator
password: admin
```
### Frappe Bench

8
crowdin.yml Normal file
View File

@@ -0,0 +1,8 @@
files:
- source: /lms/locale/main.pot
translation: /lms/locale/%two_letters_code%.po
pull_request_title: "chore: sync translations from crowdin"
pull_request_labels:
- translation
commit_message: "chore: %language% translations"
append_commit_message: false

View File

@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0,
},
e2e: {
baseUrl: "http://pyp:8000",
baseUrl: "http://test_site_ui:8000",
},
});

View File

@@ -1,133 +1,156 @@
describe("Course Creation", () => {
it("creates a new course", () => {
cy.login();
cy.visit("/courses");
cy.wait(1000);
cy.visit("/lms/courses");
// Create a course
cy.get("a.btn").contains("Create a Course").click();
cy.get("a").contains("New Course").click();
cy.wait(1000);
cy.url().should("include", "/courses/new-course/edit");
cy.get("#title").type("Test Course");
cy.get("#intro").type("Test Course Short Introduction");
cy.get("#description").type("Test Course Description");
cy.get("#video-link").type("-LPmw2Znl2c");
cy.get("#tags-input").type("Test");
cy.get("#published").check();
cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course");
cy.get("label")
.contains("Short Introduction")
.type("Test Course Short Introduction to test the UI");
cy.get("div[contenteditable=true").invoke(
"text",
"Test Course 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."
);
cy.fixture("profile.png", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "profile.png",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("label")
.contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get(".search-input").click().type("frappe");
cy.wait(1000);
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
.first()
.click();
cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click();
// Add Chapter
cy.wait(1000);
cy.link("Course Outline").click();
cy.button("Add Chapter").click();
cy.wait(1000);
cy.get(".edit-header .btn-add-chapter").click();
cy.wait(500);
cy.get("#chapter-title").type("Test Chapter");
cy.get("#chapter-description").type("Test Chapter Description");
cy.button("Save").click();
cy.get("[id^=headlessui-dialog-panel-")
.should("be.visible")
.within(() => {
cy.get("label").contains("Title").type("Test Chapter");
cy.button("Add Chapter").click();
});
// Add Lesson
cy.wait(1000);
cy.link("Add Lesson").click();
cy.button("Add Lesson").click();
cy.wait(1000);
cy.url().should("include", "/learn/1-1/edit");
cy.wait(1000);
cy.get("#lesson-title").type("Test Lesson");
// Content
cy.get(".collapse-section.collapsed:first").click();
cy.get("#lesson-content .ce-block")
cy.get("label").contains("Title").type("Test Lesson");
/* cy.get("#content .ce-block")
.click()
.type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. {enter}"
);
cy.get("#lesson-content .ce-toolbar__plus").click();
cy.get('#lesson-content [data-item-name="youtube"]').click();
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
cy.button("Insert").click();
cy.wait(1000);
.invoke("text", "https://www.youtube.com/watch?v=GoDtyItReto"); */
/* cy.get("#content .ce-block")
.click()
.paste("https://www.youtube.com/watch?v=GoDtyItReto"); */
cy.fixture("Youtube.mov", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "Youtube.mov",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("#content .ce-block").type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
cy.button("Save").click();
// View Course
cy.wait(1000);
cy.visit("/courses");
cy.get(".course-card-title:first").contains("Test Course");
cy.get(".course-card:first").click();
cy.url().should("include", "/courses/test-course");
cy.get("#title").contains("Test Course");
cy.get(".preview-video").should(
cy.visit("/lms");
cy.wait(500);
cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course");
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".course-image")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
cy.get(".grid a:first").click();
cy.url().should("include", "/lms/courses/test-course");
cy.get("div").contains("Test Course");
cy.get("div").contains("Test Course Short Introduction to test the UI");
cy.get("div").contains("Learning");
cy.get("div").contains("Frappe");
cy.get("div").contains("ERPNext");
cy.get("iframe").should(
"have.attr",
"src",
"https://www.youtube.com/embed/-LPmw2Znl2c"
);
cy.get("#intro").contains("Test Course Short Introduction");
// View Chapter
cy.get(".chapter-title-main:first").contains("Test Chapter");
cy.get(".chapter-description:first").contains(
"Test Chapter Description"
);
cy.get(".lesson-info:first").contains("Test Lesson");
cy.get(".lesson-info:first").click();
cy.get("div").contains("Test Chapter");
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
cy.get("div").contains("Test Lesson").click();
});
cy.wait(3000);
// View Lesson
cy.wait(1000);
cy.url().should("include", "learn/1.1");
cy.get("#title").contains("Test Lesson");
cy.get(".lesson-video iframe").should(
"have.attr",
"src",
"https://www.youtube.com/embed/GoDtyItReto"
);
cy.get(".lesson-content-card").contains(
cy.url().should("include", "/learn/1-1");
cy.get("div").contains("Test Lesson");
cy.get("video")
.should("be.visible")
.children("source")
.invoke("attr", "src")
.should("include", "/files/Youtube");
cy.get("div").contains(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
// Add Discussion
cy.get(".reply").click();
cy.button("New Question").click();
cy.wait(500);
cy.get(".discussion-modal").should("be.visible");
// Enter title
cy.get(".modal .topic-title")
.type("Discussion from tests")
.should("have.value", "Discussion from tests");
// Enter comment
cy.get(".modal .discussions-comment").type(
"This is a discussion from the cypress ui tests."
);
// Submit
cy.get(".modal .submit-discussion").click();
cy.wait(2000);
// Check if discussion is added to page and content is visible
cy.get(".sidebar-parent:first .discussion-topic-title").should(
"have.text",
"Discussion from tests"
);
cy.get(".sidebar-parent:first .discussion-topic-title").click();
cy.get(".discussion-on-page:visible").should("have.class", "show");
cy.get(
".discussion-on-page:visible .reply-card .reply-text .ql-editor p"
).should(
"have.text",
"This is a discussion from the cypress ui tests."
);
cy.get(".discussion-form:visible .discussions-comment").type(
"This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page."
);
cy.get(".discussion-form:visible .submit-discussion").click();
cy.wait(3000);
cy.get(".discussion-on-page:visible").should("have.class", "show");
cy.get(".discussion-on-page:visible")
.children(".reply-card")
.eq(1)
.find(".reply-text")
.should(
"have.text",
"This is a discussion from the cypress ui tests. This comment was entered through the commentbox on the page.\n"
cy.get("[id^=headlessui-dialog-panel-").within(() => {
cy.get("label").contains("Title").type("Test Discussion");
cy.get("div[contenteditable=true]").invoke(
"text",
"This is a test discussion. This will check if the UI is working properly."
);
cy.button("Post").click();
});
// View Discussion
cy.wait(500);
cy.get("div").contains("Test Discussion").click();
cy.get("div[contenteditable=true").invoke(
"text",
"This is a test comment. This will check if the UI is working properly."
);
cy.get("div").contains(
"This is a test comment. This will check if the UI is working properly."
);
});
});

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -24,6 +24,8 @@
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "cypress-file-upload";
Cypress.Commands.add("login", (email, password) => {
if (!email) {
email = Cypress.config("testUser") || "Administrator";
@@ -53,3 +55,13 @@ Cypress.Commands.add("iconButton", (text) => {
Cypress.Commands.add("dialog", (selector) => {
return cy.get(`[role=dialog] ${selector}`);
});
Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
cy.wrap(subject).then(($element) => {
const element = $element[0];
element.focus();
element.textContent = text;
const event = new Event("paste", { bubbles: true });
element.dispatchEvent(event);
});
});

Submodule frappe-ui deleted from 2898a0bdd1

5
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local

View File

@@ -0,0 +1,4 @@
{
"semi": false,
"singleQuote": true
}

42
frontend/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Frappe UI Starter
This template should help get you started developing custom frontend for Frappe
apps with Vue 3 and the Frappe UI package.
This boilerplate sets up Vue 3, Vue Router, TailwindCSS, and Frappe UI out of
the box.
## Usage
This template is meant to be cloned inside an existing Frappe App. Assuming your
apps name is `todo`. Clone this template in the root folder of your app using `degit`.
```
cd apps/todo
npx degit netchampfaris/frappe-ui-starter frontend
cd frontend
yarn
yarn dev
```
In a development environment, you need to put the below key-value pair in your `site_config.json` file:
```
"ignore_csrf": 1
```
This will prevent `CSRFToken` errors while using the vite dev server. In production environment, the `csrf_token` is attached to the `window` object in `index.html` for you.
The Vite dev server will start on the port `8080`. This can be changed from `vite.config.js`.
The development server is configured to proxy your frappe app (usually running on port `8000`). If you have a site named `todo.test`, open `http://todo.test:8080` in your browser. If you see a button named "Click to send 'ping' request", congratulations!
If you notice the browser URL is `/frontend`, this is the base URL where your frontend app will run in production.
To change this, open `src/router.js` and change the base URL passed to `createWebHistory`.
## Resources
- [Vue 3](https://v3.vuejs.org/guide/introduction.html)
- [Vue Router](https://next.router.vuejs.org/guide/)
- [Frappe UI](https://github.com/frappe/frappe-ui)
- [TailwindCSS](https://tailwindcss.com/docs/utility-first)
- [Vite](https://vitejs.dev/guide/)

49
frontend/index.html Normal file
View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frappe Learning</title>
<meta name="title" content="{{ meta.title }}" />
<meta name="image" content="{{ meta.image }}" />
<meta name="description" content="{{ meta.description }}" />
<meta name="keywords" content="{{ meta.keywords }}" />
<meta property="og:title" content="{{ meta.title }}" />
<meta property="og:image" content="{{ meta.image }}" />
<meta property="og:description" content="{{ meta.description }}" />
<meta name="twitter:title" content="{{ meta.title }}" />
<meta name="twitter:image" content="{{ meta.image }}" />
<meta name="twitter:description" content="{{ meta.description }}" />
</head>
<body>
<div id="app">
<div id="seo-content">
<h1>{{ meta.title }}</h1>
<p>
{{ meta.description }}
</p>
<p>
The content here is just for seo purposes. The actual content will be loaded in a few seconds.
</p>
<p>
Seo checks if a page has more than 300 words. So, here are some more words to make it more than 300 words.
Page descriptions are the HTML meta tags that provide a brief summary of a web page.
Search engines use meta descriptions to help identify the page's topic - they don't use them to rank the page, but they do use them to determine whether or not to display the page in search results.
Meta descriptions are important because they're often the first thing people see when they're deciding which search result to click on.
They're also important because they can help improve your click-through rate (CTR) from search results.
A good meta description can entice people to click on your page instead of someone else's.
</p>
<a href="{{ meta.link }}">Know More</a>
</div>
</div>
<div id="modals"></div>
<div id="popovers"></div>
<script>
window.csrf_token = '{{ csrf_token }}'
document.getElementById('seo-content').style.display = 'none';
</script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

42
frontend/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "frappe-ui-frontend",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"serve": "vite preview",
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry",
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
},
"dependencies": {
"@editorjs/checklist": "^1.6.0",
"@editorjs/code": "^2.9.0",
"@editorjs/editorjs": "^2.29.0",
"@editorjs/embed": "^2.7.0",
"@editorjs/header": "^2.8.1",
"@editorjs/inline-code": "^1.5.0",
"@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0",
"chart.js": "^4.4.1",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.56",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",
"socket.io-client": "^4.7.2",
"tailwindcss": "^3.3.3",
"vue": "^3.4.23",
"vue-chartjs": "^5.3.0",
"vue-draggable-next": "^2.2.1",
"vue-router": "^4.0.12",
"vuedraggable": "4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"autoprefixer": "^10.4.2",
"postcss": "^8.4.5",
"vite": "^5.0.11"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
frontend/public/Youtube.mov Normal file

Binary file not shown.

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

25
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,25 @@
<template>
<Layout>
<router-view />
</Layout>
<Dialogs />
<Toasts />
</template>
<script setup>
import { Toasts } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs'
import { computed, defineAsyncComponent } from 'vue'
import { useScreenSize } from './utils/composables'
import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue'
const screenSize = useScreenSize()
const Layout = computed(() => {
if (screenSize.width < 640) {
return MobileLayout
} else {
return DesktopLayout
}
})
</script>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,152 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
url("Inter-Thin.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 100;
font-display: swap;
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
url("Inter-ThinItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 200;
font-display: swap;
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
url("Inter-ExtraLight.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 200;
font-display: swap;
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
url("Inter-Light.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 300;
font-display: swap;
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
url("Inter-LightItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
url("Inter-Regular.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
url("Inter-Italic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
url("Inter-Medium.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 500;
font-display: swap;
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
url("Inter-MediumItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
url("Inter-SemiBold.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
url("Inter-Bold.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-BoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
url("Inter-ExtraBold.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 800;
font-display: swap;
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
url("Inter-Black.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 900;
font-display: swap;
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
url("Inter-BlackItalic.woff?v=3.12") format("woff");
}

View File

@@ -0,0 +1,62 @@
<template>
<div v-if="communications.data?.length">
<div v-for="comm in communications.data">
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Avatar :label="comm.sender_full_name" size="lg" />
<div class="ml-2">
{{ comm.sender_full_name }}
</div>
</div>
<div class="text-sm">
{{ timeAgo(comm.communication_date) }}
</div>
</div>
<div
class="prose prose-sm bg-gray-50 !min-w-full px-4 py-2 rounded-md"
v-html="comm.content"
></div>
</div>
</div>
</div>
<div v-else class="text-sm italic text-gray-600">
{{ __('No announcements') }}
</div>
</template>
<script setup>
import { createListResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils'
const props = defineProps({
batch: {
type: String,
required: true,
},
})
const communications = createListResource({
doctype: 'Communication',
fields: [
'subject',
'content',
'recipients',
'cc',
'communication_date',
'sender',
'sender_full_name',
],
filters: {
reference_doctype: 'LMS Batch',
reference_name: props.batch,
},
orderBy: 'communication_date desc',
auto: true,
cache: ['batch', props.batch],
})
</script>
<style>
.prose-sm p {
margin: 0 0 0.5rem;
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
:class="isSidebarCollapsed ? 'w-14' : 'w-56'"
>
<div
class="flex flex-col overflow-hidden"
:class="isSidebarCollapsed ? 'items-center' : ''"
>
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
<div class="flex flex-col" v-if="sidebarSettings.data">
<SidebarLink
v-for="link in sidebarLinks"
:link="link"
:isCollapsed="isSidebarCollapsed"
class="mx-2 my-0.5"
/>
</div>
<div
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
class="mt-4"
>
<div
class="flex items-center justify-between pr-2 cursor-pointer"
:class="isSidebarCollapsed ? 'pl-3' : 'pl-4'"
@click="showWebPages = !showWebPages"
>
<div
v-if="!isSidebarCollapsed"
class="flex items-center text-sm text-gray-600 my-1"
>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<ChevronRight
class="h-4 w-4 stroke-1.5 text-gray-900 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': showWebPages }"
/>
</span>
<span class="ml-2">
{{ __('More') }}
</span>
</div>
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
<template #icon>
<Plus class="h-4 w-4 text-gray-700 stroke-1.5" />
</template>
</Button>
</div>
<div
v-if="sidebarSettings.data?.web_pages?.length"
class="flex flex-col transition-all duration-300 ease-in-out"
:class="showWebPages ? 'block' : 'hidden'"
>
<SidebarLink
v-for="link in sidebarSettings.data.web_pages"
:link="link"
:isCollapsed="isSidebarCollapsed"
class="mx-2 my-0.5"
:showControls="isModerator ? true : false"
@openModal="openPageModal"
@deletePage="deletePage"
/>
</div>
</div>
</div>
<SidebarLink
:link="{
label: isSidebarCollapsed ? 'Expand' : 'Collapse',
}"
:isCollapsed="isSidebarCollapsed"
@click="isSidebarCollapsed = !isSidebarCollapsed"
class="m-2"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CollapseSidebar
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
/>
</span>
</template>
</SidebarLink>
</div>
<PageModal
v-model="showPageModal"
v-model:reloadSidebar="sidebarSettings"
:page="pageToEdit"
/>
</template>
<script setup>
import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import { useStorage } from '@vueuse/core'
import { ref, onMounted, inject, watch } from 'vue'
import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
import { ChevronRight, Plus } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue'
const { user } = sessionStore()
const { userResource } = usersStore()
const socket = inject('$socket')
const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks())
const showPageModal = ref(false)
const isModerator = ref(false)
const pageToEdit = ref(null)
const showWebPages = ref(false)
onMounted(() => {
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
addNotifications()
})
const unreadNotifications = createResource({
cache: 'Unread Notifications Count',
url: 'frappe.client.get_count',
makeParams(values) {
return {
doctype: 'Notification Log',
filters: {
for_user: user,
read: 0,
},
}
},
onSuccess(data) {
unreadCount.value = data
sidebarLinks.value = sidebarLinks.value.map((link) => {
if (link.label === 'Notifications') {
link.count = data
}
return link
})
},
auto: user ? true : false,
})
const addNotifications = () => {
if (user) {
sidebarLinks.value.push({
label: 'Notifications',
icon: 'Bell',
to: 'Notifications',
activeFor: ['Notifications'],
count: unreadCount.value,
})
}
}
const sidebarSettings = createResource({
url: 'lms.lms.api.get_sidebar_settings',
cache: 'Sidebar Settings',
auto: true,
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
})
},
})
const openPageModal = (link) => {
showPageModal.value = true
pageToEdit.value = link
}
const deletePage = (link) => {
createResource({
url: 'lms.lms.api.delete_sidebar_item',
makeParams(values) {
return {
webpage: link.web_page,
}
},
}).submit(
{},
{
onSuccess() {
sidebarSettings.reload()
},
}
)
}
const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false)
}
watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
}
})
let isSidebarCollapsed = ref(getSidebarFromStorage())
</script>

View File

@@ -0,0 +1,98 @@
<template>
<div>
<div class="text-lg font-semibold mb-4">
{{ __('Assessments') }}
</div>
<div v-if="assessments.data?.length">
<ListView
:columns="getAssessmentColumns()"
:rows="assessments.data"
row-key="name"
:options="{
selectable: false,
showTooltip: false,
getRowRoute: (row) => {
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: 'new',
},
}
}
},
}"
>
</ListView>
</div>
<div v-else class="text-sm italic text-gray-600">
{{ __('No Assessments') }}
</div>
</div>
</template>
<script setup>
import { ListView, createResource } from 'frappe-ui'
import { inject } from 'vue'
const user = inject('$user')
const props = defineProps({
batch: {
type: String,
required: true,
},
rows: {
type: Array,
},
columns: {
type: Array,
},
options: {
type: Object,
default: () => ({
selectable: true,
totalCount: 0,
rowCount: 0,
}),
},
})
const assessments = createResource({
url: 'lms.lms.utils.get_assessments',
params: {
batch: props.batch,
},
auto: true,
})
const getAssessmentColumns = () => {
let columns = [
{
label: 'Assessment',
key: 'title',
},
{
label: 'Type',
key: 'assessment_type',
},
]
if (!user.data?.is_moderator) {
columns.push({
label: 'Status/Score',
key: 'status',
align: 'center',
})
}
return columns
}
</script>

View File

@@ -0,0 +1,135 @@
<template>
<div>
<!-- <audio width="100%" controls controlsList="nodownload" class="mb-4">
<source :src="encodeURI(file)" type="audio/mp3" />
</audio> -->
<audio @ended="handleAudioEnd" controlsList="nodownload" class="mb-4">
<source :src="encodeURI(file)" type="audio/mp3" />
</audio>
<div class="flex items-center space-x-2 shadow rounded-lg p-1 w-1/2">
<Button variant="ghost" @click="togglePlay">
<template #icon>
<Play v-if="!isPlaying" class="w-4 h-4 text-gray-900" />
<Pause v-else class="w-4 h-4 text-gray-900" />
</template>
</Button>
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider w-full h-1"
/>
<span class="text-xs text-gray-900 font-medium">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
<Button variant="ghost" @click="toggleMute">
<template #icon>
<Volume2 v-if="!isMuted" class="w-4 h-4 text-gray-900" />
<VolumeX v-else class="w-4 h-4 text-gray-900" />
</template>
</Button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { Play, Pause, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui'
const isPlaying = ref(false)
const audio = ref(null)
let isMuted = ref(false)
let currentTime = ref(0)
let duration = ref(0)
const props = defineProps({
file: {
type: String,
required: true,
},
})
onMounted(() => {
setTimeout(() => {
audio.value = document.querySelector('audio')
console.log(audio.value)
audio.value.onloadedmetadata = () => {
duration.value = audio.value.duration
}
audio.value.ontimeupdate = () => {
currentTime.value = audio.value.currentTime
}
}, 0)
})
const togglePlay = () => {
if (audio.value.paused) {
audio.value.play()
isPlaying.value = true
} else {
audio.value.pause()
isPlaying.value = false
}
}
const toggleMute = () => {
audio.value.muted = !audio.value.muted
isMuted.value = audio.value.muted
}
const changeCurrentTime = () => {
audio.value.currentTime = currentTime.value
}
const handleAudioEnd = () => {
isPlaying.value = false
}
const formatTime = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}
watch(isPlaying, (newVal) => {
if (newVal) {
audio.value.play()
} else {
audio.value.pause()
}
})
</script>
<style>
.duration-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
background-color: theme('colors.gray.400');
cursor: pointer;
}
.duration-slider::-webkit-slider-thumb {
height: 10px;
width: 10px;
-webkit-appearance: none;
background-color: theme('colors.gray.900');
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
input[type='range'] {
overflow: hidden;
width: 150px;
-webkit-appearance: none;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
cursor: pointer;
box-shadow: -150px 0 0 150px theme('colors.gray.900');
}
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full"
style="min-height: 150px"
>
<div class="text-xl font-semibold mb-2">
{{ batch.title }}
</div>
<Badge
v-if="batch.seat_count && batch.seats_left > 0"
theme="green"
class="self-start mb-2"
>
{{ batch.seats_left }}
<span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span
><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
</Badge>
<Badge
v-else-if="batch.seat_count && batch.seats_left <= 0"
theme="red"
class="self-start mb-2"
>
{{ __('Sold Out') }}
</Badge>
<div class="short-introduction">
{{ batch.description }}
</div>
<div class="flex flex-col space-y-2 mt-auto">
<div v-if="batch.amount" class="font-semibold text-lg">
{{ batch.price }}
</div>
<DateRange
:startDate="batch.start_date"
:endDate="batch.end_date"
class="text-sm text-gray-700 mb-3"
/>
<div class="flex items-center text-sm text-gray-700">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
</span>
</div>
<div
v-if="batch.timezone"
class="flex items-center text-sm text-gray-700"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-600" />
<span>
{{ batch.timezone }}
</span>
</div>
<div v-if="batch.instructors?.length" class="flex avatar-group overlap">
<div
class="h-6 mr-1"
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
>
<UserAvatar
v-for="instructor in batch.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.instructors" />
</div>
</div>
</div>
</template>
<script setup>
import { Badge } from 'frappe-ui'
import { formatTime } from '../utils'
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
</script>
<style>
.short-introduction {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.25rem;
line-height: 1.5;
}
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar {
transition: margin 0.1s ease-in-out;
}
.avatar-group.overlap .avatar + .avatar {
margin-left: calc(-8px);
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold">
{{ __('Courses') }}
</div>
<Button
v-if="user.data?.is_moderator"
variant="solid"
@click="openCourseModal()"
>
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add Course') }}
</Button>
</div>
<div v-if="courses.data?.length">
<ListView
:columns="getCoursesColumns()"
:rows="courses.data"
row-key="batch_course"
:options="{
showTooltip: false,
getRowRoute: (row) => ({
name: 'CourseDetail',
params: { courseName: row.name },
}),
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
<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 courses.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeCourses(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<BatchCourseModal
v-model="showCourseModal"
:batch="batch"
v-model:courses="courses"
/>
</div>
</template>
<script setup>
import { ref, inject } from 'vue'
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
import {
createResource,
Button,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRow,
ListRows,
ListView,
ListRowItem,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
const showCourseModal = ref(false)
const user = inject('$user')
const props = defineProps({
batch: {
type: String,
required: true,
},
})
const courses = createResource({
url: 'lms.lms.utils.get_batch_courses',
params: {
batch: props.batch,
},
cache: ['batchCourses', props.batchName],
auto: true,
})
const openCourseModal = () => {
showCourseModal.value = true
}
const getCoursesColumns = () => {
return [
{
label: 'Title',
key: 'title',
width: 2,
},
{
label: 'Lessons',
key: 'lesson_count',
align: 'right',
},
{
label: 'Enrollments',
align: 'right',
key: 'enrollment_count',
},
]
}
const removeCourse = createResource({
url: 'frappe.client.delete',
makeParams(values) {
return {
doctype: 'Batch Course',
name: values.course,
}
},
})
const removeCourses = (selections, unselectAll) => {
selections.forEach(async (course) => {
removeCourse.submit({ course })
})
setTimeout(() => {
courses.reload()
unselectAll()
}, 1000)
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div>
<UpcomingEvaluations
:batch="batch.data.name"
:endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses"
:isStudent="isStudent"
/>
<Assessments :batch="batch.data.name" />
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue'
const props = defineProps({
batch: {
type: Object,
default: null,
},
isStudent: {
type: Boolean,
default: false,
},
})
</script>

View File

@@ -0,0 +1,128 @@
<template>
<div v-if="batch.data" class="shadow rounded-md p-5 lg:w-72">
<Badge
v-if="batch.data.seat_count && seats_left > 0"
theme="green"
class="self-start mb-2 float-right"
>
{{ seats_left }} <span v-if="seats_left > 1">{{ __('Seats Left') }}</span
><span v-else-if="seats_left == 1">{{ __('Seat Left') }}</span>
</Badge>
<Badge
v-else-if="batch.data.seat_count && seats_left <= 0"
theme="red"
class="self-start mb-2 float-right"
>
{{ __('Sold Out') }}
</Badge>
<div v-if="batch.data.amount" class="text-lg font-semibold mb-3">
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div>
<div class="flex items-center mb-3">
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-3">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div>
<div v-if="batch.data.timezone" class="flex items-center">
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ batch.data.timezone }}
</span>
</div>
<router-link
v-if="isModerator || isStudent"
:to="{
name: 'Batch',
params: {
batchName: batch.data.name,
},
}"
>
<Button variant="solid" class="w-full mt-4">
<span>
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
</span>
</Button>
</router-link>
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-else-if="batch.data.paid_batch && batch.data.seats_left"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="isModerator"
:to="{
name: 'BatchCreation',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
</template>
<script setup>
import { inject, computed } from 'vue'
import { Badge, Button } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
const user = inject('$user')
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
const seats_left = computed(() => {
if (props.batch.data?.seat_count) {
return props.batch.data?.seat_count - props.batch.data?.students?.length
}
return null
})
const isStudent = computed(() => {
return props.batch.data?.students?.includes(user.data?.name)
})
const isModerator = computed(() => {
return user.data?.is_moderator
})
</script>

View File

@@ -0,0 +1,157 @@
<template>
<Button class="float-right mb-3" variant="solid" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add Student') }}
</Button>
<div class="text-lg font-semibold mb-4">
{{ __('Students') }}
</div>
<div v-if="students.data?.length">
<ListView
:columns="getStudentColumns()"
:rows="students.data"
row-key="name"
:options="{ showTooltip: false }"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in getStudentColumns()">
<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 students.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'full_name'">
<Avatar
class="flex items-center"
:image="row['user_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-gray-600">
{{ __('There are no students in this batch.') }}
</div>
<StudentModal
:batch="props.batch"
v-model="showStudentModal"
v-model:reloadStudents="students"
/>
</template>
<script setup>
import {
createResource,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRow,
ListRows,
ListView,
ListRowItem,
Avatar,
Button,
} from 'frappe-ui'
import { Trash2, Plus } from 'lucide-vue-next'
import { ref } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
const showStudentModal = ref(false)
const props = defineProps({
batch: {
type: String,
default: null,
},
})
const students = createResource({
url: 'lms.lms.utils.get_batch_students',
cache: ['students', props.batch],
params: {
batch: props.batch,
},
auto: true,
})
const getStudentColumns = () => {
return [
{
label: 'Full Name',
key: 'full_name',
width: 2,
},
{
label: 'Courses Done',
key: 'courses_completed',
align: 'center',
},
{
label: 'Assessments Done',
key: 'assessments_completed',
align: 'center',
},
{
label: 'Last Active',
key: 'last_active',
},
]
}
const openStudentModal = () => {
showStudentModal.value = true
}
const removeStudent = createResource({
url: 'frappe.client.delete',
makeParams(values) {
return {
doctype: 'Batch Student',
name: values.student,
}
},
})
const removeStudents = (selections, unselectAll) => {
selections.forEach(async (student) => {
removeStudent.submit({ student })
})
setTimeout(() => {
students.reload()
unselectAll()
}, 500)
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div class="flex items-center">
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ getFormattedDateRange(props.startDate, props.endDate) }}
</span>
</div>
</template>
<script setup>
import { Calendar } from 'lucide-vue-next'
import { getFormattedDateRange } from '@/utils'
const props = defineProps({
startDate: {
type: String,
},
endDate: {
type: String,
},
})
</script>

View File

@@ -0,0 +1,277 @@
<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-gray-500" 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-white py-1 text-base shadow-2xl">
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<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" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<div
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
>
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
>
{{ 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-1.5 text-base',
{ 'bg-gray-100': active },
]"
>
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
>
{{ option.label }}
</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-gray-600"
>
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>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import { Popover, Button } from 'frappe-ui'
import { ChevronDown, X } from 'lucide-vue-next'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
size: {
type: String,
default: 'md',
},
variant: {
type: String,
default: 'subtle',
},
placeholder: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
filterable: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
const query = ref('')
const showOptions = ref(false)
const search = ref(null)
const attrs = useAttrs()
const slots = useSlots()
const valuePropPassed = computed(() => 'value' in attrs)
const selectedValue = computed({
get() {
return valuePropPassed.value ? attrs.value : props.modelValue
},
set(val) {
query.value = ''
if (val) {
showOptions.value = false
}
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
},
})
function close() {
showOptions.value = false
}
const groups = computed(() => {
if (!props.options || props.options.length == 0) return []
let groups = props.options[0]?.group
? props.options
: [{ group: '', items: props.options }]
return groups
.map((group, i) => {
return {
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: props.filterable ? filterOptions(group.items) : group.items,
}
})
.filter((group) => group.items.length > 0)
})
function filterOptions(options) {
if (!query.value) {
return options
}
return options.filter((option) => {
let searchTexts = [option.label, option.value]
return searchTexts.some((text) =>
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
)
})
}
function displayValue(option) {
if (typeof option === 'string') {
let allOptions = groups.value.flatMap((group) => group.items)
let selectedOption = allOptions.find((o) => o.value === option)
return selectedOption?.label || option
}
return option?.label
}
watch(query, (q) => {
emit('update:query', q)
})
watch(showOptions, (val) => {
if (val) {
nextTick(() => {
search.value.el.focus()
})
}
})
const textColor = computed(() => {
return props.disabled ? 'text-gray-600' : 'text-gray-800'
})
const inputClasses = computed(() => {
let sizeClasses = {
sm: 'text-base rounded h-7',
md: 'text-base rounded h-8',
lg: 'text-lg rounded-md h-10',
xl: 'text-xl rounded-md h-10',
}[props.size]
let paddingClasses = {
sm: 'py-1.5 px-2',
md: 'py-1.5 px-2.5',
lg: 'py-1.5 px-3',
xl: 'py-1.5 px-3',
}[props.size]
let variant = props.disabled ? 'disabled' : props.variant
let variantClasses = {
subtle:
'border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
outline:
'border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
disabled: [
'border bg-gray-50 placeholder-gray-400',
props.variant === 'outline' ? 'border-gray-300' : 'border-transparent',
],
}[variant]
return [
sizeClasses,
paddingClasses,
variantClasses,
textColor.value,
'transition-colors w-full',
]
})
defineExpose({ query })
</script>

View File

@@ -0,0 +1,114 @@
<template>
<div class="space-y-1.5">
<label class="block text-xs text-gray-600">
{{ label }}
</label>
<div class="w-full">
<Popover>
<template #target="{ togglePopover }">
<button
@click="openPopover(togglePopover)"
class="flex w-full items-center space-x-2 focus:outline-none bg-gray-100 rounded h-7 py-1.5 px-2 hover:bg-gray-200 focus:bg-white border border-gray-100 hover:border-gray-200 focus:border-gray-500"
>
<component
v-if="selectedIcon"
class="w-4 h-4 text-gray-700 stroke-1.5"
:is="icons[selectedIcon]"
/>
<component
v-else
class="w-4 h-4 text-gray-700 stroke-1.5"
:is="icons.Folder"
/>
<span v-if="selectedIcon">
{{ selectedIcon }}
</span>
<span v-else class="text-gray-600">
{{ __('Choose an icon') }}
</span>
</button>
</template>
<template #body-main="{ close, isOpen }" class="w-full">
<div class="p-3 max-h-56 overflow-auto w-full">
<FormControl
ref="search"
v-model="iconQuery"
:placeholder="__('Search for an icon')"
autocomplete="off"
/>
<div class="grid grid-cols-10 gap-4 mt-4">
<div v-for="(iconComponent, iconName) in filteredIcons">
<component
:is="iconComponent"
class="h-4 w-4 stroke-1.5 text-gray-700 cursor-pointer"
@click="setIcon(iconName, close)"
/>
</div>
</div>
</div>
</template>
</Popover>
</div>
</div>
</template>
<script setup>
import { FormControl, Popover } from 'frappe-ui'
import * as icons from 'lucide-vue-next'
import { ref, computed, onMounted, nextTick } from 'vue'
const iconQuery = ref('')
const selectedIcon = ref('')
const search = ref(null)
const emit = defineEmits(['update:modelValue', 'change'])
const iconArray = ref(
Object.keys(icons)
.sort(() => 0.5 - Math.random())
.slice(0, 100)
.reduce((result, key) => {
result[key] = icons[key]
return result
}, {})
)
const props = defineProps({
label: {
type: String,
default: 'Icon',
},
modelValue: {
type: String,
default: '',
},
})
onMounted(() => {
selectedIcon.value = props.modelValue
})
const setIcon = (icon, close) => {
emit('update:modelValue', icon)
selectedIcon.value = icon
iconQuery.value = ''
close()
}
const filteredIcons = computed(() => {
if (!iconQuery.value) {
return iconArray.value
}
return Object.keys(icons)
.filter((icon) =>
icon.toLowerCase().includes(iconQuery.value.toLowerCase())
)
.reduce((result, key) => {
result[key] = icons[key]
return result
}, {})
})
const openPopover = (togglePopover) => {
togglePopover()
}
</script>

View File

@@ -0,0 +1,146 @@
<template>
<div class="space-y-1.5">
<label class="block" :class="labelClasses" v-if="attrs.label">
{{ attrs.label }}
</label>
<Autocomplete
ref="autocomplete"
:options="options.data"
v-model="value"
:size="attrs.size || 'sm'"
:variant="attrs.variant"
:placeholder="attrs.placeholder"
:filterable="false"
>
<template #target="{ open, togglePopover }">
<slot name="target" v-bind="{ open, togglePopover }" />
</template>
<template #prefix>
<slot name="prefix" />
</template>
<template #item-prefix="{ active, selected, option }">
<slot name="item-prefix" v-bind="{ active, selected, option }" />
</template>
<template #item-label="{ active, selected, option }">
<slot name="item-label" v-bind="{ active, selected, option }" />
</template>
<template v-if="attrs.onCreate" #footer="{ value, close }">
<div>
<Button
variant="ghost"
class="w-full !justify-start"
label="Create New"
@click="attrs.onCreate(value, close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</template>
</Autocomplete>
</div>
</template>
<script setup>
import Autocomplete from '@/components/Controls/Autocomplete.vue'
import { watchDebounced } from '@vueuse/core'
import { createResource, Button } from 'frappe-ui'
import { Plus } from 'lucide-vue-next'
import { useAttrs, computed, ref } from 'vue'
const props = defineProps({
doctype: {
type: String,
required: true,
},
filters: {
type: Object,
default: () => ({}),
},
modelValue: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:modelValue', 'change'])
const attrs = useAttrs()
const valuePropPassed = computed(() => 'value' in attrs)
const value = computed({
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
set: (val) => {
return (
val?.value &&
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
)
},
})
const autocomplete = ref(null)
const text = ref('')
watchDebounced(
() => autocomplete.value?.query,
(val) => {
val = val || ''
if (text.value === val) return
text.value = val
reload(val)
},
{ debounce: 300, immediate: true }
)
watchDebounced(
() => props.doctype,
() => reload(''),
{ debounce: 300, immediate: true }
)
const options = createResource({
url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value],
method: 'POST',
params: {
txt: text.value,
doctype: props.doctype,
filters: props.filters,
},
transform: (data) => {
return data.map((option) => {
return {
label: option.value,
value: option.value,
}
})
},
})
function reload(val) {
options.update({
params: {
txt: val,
doctype: props.doctype,
filters: props.filters,
},
})
options.reload()
}
const labelClasses = computed(() => {
return [
{
sm: 'text-xs',
md: 'text-base',
}[attrs.size || 'sm'],
'text-gray-600',
]
})
</script>

View File

@@ -0,0 +1,255 @@
<template>
<div>
<label class="block mb-1" :class="labelClasses" v-if="label">
{{ label }}
</label>
<div class="grid grid-cols-3 gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
class="rounded-md"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
</template>
</Button>
<div class="">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full focus-visible:!ring-0"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-gray-100': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium">
{{ option.description }}
</div>
<div class="text-sm text-gray-600">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
</div>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
</div>
</template>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { X } from 'lucide-vue-next'
const props = defineProps({
label: {
type: String,
},
size: {
type: String,
default: 'sm',
},
doctype: {
type: String,
required: true,
},
filters: {
type: Object,
default: () => ({}),
},
validate: {
type: Function,
default: null,
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
})
const values = defineModel()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
if (val) {
showOptions.value = false
}
val?.value && addValue(val.value)
},
})
watchDebounced(
query,
(val) => {
val = val || ''
if (text.value === val) return
text.value = val
reload(val)
},
{ debounce: 300, immediate: true }
)
const filterOptions = createResource({
url: 'frappe.desk.search.search_link',
method: 'POST',
cache: [text.value, props.doctype],
params: {
txt: text.value,
doctype: props.doctype,
},
/* transform: (data) => {
let allData = data
.filter((c) => {
return c.description.split(', ')[1]
})
.map((option) => {
let email = option.description.split(', ')[1]
return {
label: option.label || email,
value: email,
}
})
return allData
}, */
})
const options = computed(() => {
return filterOptions.data || []
})
function reload(val) {
filterOptions.update({
params: {
txt: val,
doctype: props.doctype,
},
})
filterOptions.reload()
}
const addValue = (value) => {
error.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
}
}
})
!error.value && (value = '')
}
}
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
}
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
const labelClasses = computed(() => {
return [
{
sm: 'text-xs',
md: 'text-base',
}[props.size || 'sm'],
'text-gray-600',
]
})
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="flex text-center">
<div v-for="index in 5">
<Star
:class="index <= rating ? 'fill-orange-500' : ''"
class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer"
@click="markRating(index)"
/>
</div>
</div>
</template>
<script setup>
import { Star } from 'lucide-vue-next'
import { ref } from 'vue'
const props = defineProps({
id: {
type: String,
default: '',
},
modelValue: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['update:modelValue'])
let rating = ref(props.modelValue)
let emitChange = (value) => {
emit('update:modelValue', value)
}
function markRating(index) {
emitChange(index)
rating.value = index
}
</script>

View File

@@ -0,0 +1,186 @@
<template>
<div
v-if="course.title"
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
style="min-height: 350px"
>
<div
class="course-image"
:class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
>
<div
class="flex items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
>
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
{{ __('Featured') }}
</Badge>
<Badge
variant="outline"
theme="gray"
size="md"
v-for="tag in course.tags"
>
{{ tag }}
</Badge>
</div>
<div v-if="!course.image" class="image-placeholder">
{{ course.title[0] }}
</div>
</div>
<div class="flex flex-col flex-auto p-4">
<div class="flex items-center justify-between mb-2">
<div v-if="course.lesson_count">
<Tooltip :text="__('Lessons')">
<span class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.lesson_count }}
</span>
</Tooltip>
</div>
<div v-if="course.enrollment_count">
<Tooltip :text="__('Enrolled Students')">
<span class="flex items-center">
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.enrollment_count }}
</span>
</Tooltip>
</div>
<div v-if="course.avg_rating">
<Tooltip :text="__('Average Rating')">
<span class="flex items-center">
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.avg_rating }}
</span>
</Tooltip>
</div>
<div v-if="course.status != 'Approved'">
<Badge
variant="solid"
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
size="sm"
>
{{ course.status }}
</Badge>
</div>
</div>
<div class="text-xl font-semibold leading-6">
{{ course.title }}
</div>
<div class="short-introduction">
{{ course.short_introduction }}
</div>
<ProgressBar
v-if="user && course.membership"
:progress="course.membership.progress"
/>
<div v-if="user && course.membership" class="text-sm mb-4">
{{ Math.ceil(course.membership.progress) }}% completed
</div>
<div class="flex items-center justify-between mt-auto">
<div class="flex avatar-group overlap">
<div
class="h-6 mr-1"
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
>
<UserAvatar
v-for="instructor in course.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="course.instructors" />
</div>
<div class="font-semibold">
{{ course.price }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { BookOpen, Users, Star } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue'
import { sessionStore } from '@/stores/session'
import { Badge, Tooltip } from 'frappe-ui'
import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
const { user } = sessionStore()
const props = defineProps({
course: {
type: Object,
default: null,
},
})
</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;
margin-right: 0.5rem;
padding: 3.5px 8px;
font-size: 11px;
text-align: center;
letter-spacing: 0.011em;
text-transform: uppercase;
font-weight: 600;
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;
}
.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);
}
.short-introduction {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.25rem;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="shadow rounded-md min-w-80">
<iframe
v-if="course.data.video_link"
:src="video_link"
class="rounded-t-md min-h-56 w-full"
/>
<div class="p-5">
<div v-if="course.data.price" class="text-2xl font-semibold mb-3">
{{ course.data.price }}
</div>
<router-link
v-if="course.data.membership"
:to="{
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<span>
{{ __('Continue Learning') }}
</span>
</Button>
</router-link>
<router-link
v-else-if="course.data.paid_course"
:to="{
name: 'Billing',
params: {
type: 'course',
name: course.data.name,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<span>
{{ __('Buy this course') }}
</span>
</Button>
</router-link>
<div
v-else-if="course.data.disable_self_learning"
class="bg-blue-100 text-blue-900 text-sm rounded-md py-1 px-3"
>
{{ __('Contact the Administrator to enroll for this course.') }}
</div>
<Button
v-else
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<span>
{{ __('Start Learning') }}
</span>
</Button>
<Button
v-if="canGetCertificate"
@click="fetchCertificate()"
variant="subtle"
class="w-full mt-2"
size="md"
>
{{ __('Get Certificate') }}
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CreateCourse',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
<div class="mt-8 mb-4 font-medium">
{{ __('This course has:') }}
</div>
<div class="flex items-center mb-3">
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
<span class="ml-2">
{{ course.data.lesson_count }} {{ __('Lessons') }}
</span>
</div>
<div class="flex items-center mb-3">
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
<span class="ml-2">
{{ course.data.enrollment_count_formatted }}
{{ __('Enrolled Students') }}
</span>
</div>
<div class="flex items-center">
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
<span class="ml-2">
{{ course.data.avg_rating }} {{ __('Rating') }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { BookOpen, Users, Star } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui'
import { createToast } from '@/utils/'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user')
const props = defineProps({
course: {
type: Object,
default: null,
},
})
const video_link = computed(() => {
if (props.course.data.video_link) {
return 'https://www.youtube.com/embed/' + props.course.data.video_link
}
return null
})
function enrollStudent() {
if (!user.data) {
createToast({
title: 'Please Login',
icon: 'alert-circle',
iconClasses: 'text-yellow-600 bg-yellow-100',
})
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 2000)
} else {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
})
enrollStudentResource
.submit({
course: props.course.data.name,
})
.then(() => {
createToast({
title: 'Enrolled Successfully',
icon: 'check',
iconClasses: 'text-green-600 bg-green-100',
})
setTimeout(() => {
router.push({
name: 'Lesson',
params: {
courseName: props.course.data.name,
chapterNumber: 1,
lessonNumber: 1,
},
})
}, 3000)
})
}
}
const is_instructor = () => {
let user_is_instructor = false
props.course.data.instructors.forEach((instructor) => {
if (!user_is_instructor && instructor.name == user.data?.name) {
user_is_instructor = true
}
})
return user_is_instructor
}
const canGetCertificate = computed(() => {
if (
props.course.data?.enable_certification &&
props.course.data?.membership?.progress == 100
) {
return true
}
return false
})
const certificate = createResource({
url: 'lms.lms.doctype.lms_certificate.lms_certificate.create_certificate',
makeParams(values) {
return {
course: values.course,
}
},
onSuccess(data) {
console.log(data)
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
data.name
}&format=${encodeURIComponent(data.template)}`,
'_blank'
)
},
})
const fetchCertificate = () => {
certificate.submit({
course: props.course.data?.name,
member: user.data?.name,
})
}
</script>

View File

@@ -0,0 +1,50 @@
<template>
<span v-if="instructors.length == 1">
<router-link
:to="{
name: 'Profile',
params: { username: instructors[0].username },
}"
>
{{ instructors[0].full_name }}
</router-link>
</span>
<span v-if="instructors.length == 2">
<router-link
:to="{
name: 'Profile',
params: { username: instructors[0].username },
}"
>
{{ instructors[0].first_name }}
</router-link>
and
<router-link
:to="{
name: 'Profile',
params: { username: instructors[1].username },
}"
>
{{ instructors[1].first_name }}
</router-link>
</span>
<span v-if="instructors.length > 2">
<router-link
:to="{
name: 'Profile',
params: { username: instructors[0].username },
}"
>
{{ instructors[0].first_name }}
</router-link>
and {{ instructors.length - 1 }} others
</span>
</template>
<script setup>
const props = defineProps({
instructors: {
type: Array,
required: true,
},
})
</script>

View File

@@ -0,0 +1,236 @@
<template>
<div class="text-base">
<div
v-if="title && (outline.data?.length || allowEdit)"
class="grid grid-cols-[70%,30%] mb-4 px-2"
>
<div class="font-semibold text-lg">
{{ __(title) }}
</div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }}
</Button>
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
</span> -->
</div>
<div
:class="{
'shadow rounded-md pt-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)"
>
<DisclosureButton ref="" class="flex w-full p-2">
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
open: index == 1,
}"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
<div class="text-base text-left font-medium leading-5">
{{ chapter.title }}
</div>
</DisclosureButton>
<DisclosurePanel>
<Draggable
:list="chapter.lessons"
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">
<router-link
:to="{
name: allowEdit ? 'CreateLesson' : '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 text-gray-900 stroke-1 mr-2"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
{{ lesson.title }}
<Trash2
v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)"
class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
/>
<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
:to="{
name: 'CreateLesson',
params: {
courseName: courseName,
chapterNumber: chapter.idx,
lessonNumber: chapter.lessons.length + 1,
},
}"
>
<Button>
{{ __('Add Lesson') }}
</Button>
</router-link>
<Button class="ml-2" @click="openChapterModal(chapter)">
{{ __('Edit Chapter') }}
</Button>
</div>
</DisclosurePanel>
</Disclosure>
</div>
</div>
<ChapterModal
v-model="showChapterModal"
v-model:outline="outline"
:course="courseName"
:chapterDetail="getCurrentChapter()"
/>
</template>
<script setup>
import { Button, createResource } from 'frappe-ui'
import { ref } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
ChevronRight,
MonitorPlay,
HelpCircle,
FileText,
Check,
Trash2,
} from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils'
const route = useRoute()
const expandAll = ref(true)
const showChapterModal = ref(false)
const currentChapter = ref(null)
const props = defineProps({
courseName: {
type: String,
required: true,
},
showOutline: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
allowEdit: {
type: Boolean,
default: false,
},
getProgress: {
type: Boolean,
default: false,
},
})
const outline = createResource({
url: 'lms.lms.utils.get_course_outline',
cache: ['course_outline', props.courseName],
params: {
course: props.courseName,
progress: props.getProgress,
},
auto: true,
})
const deleteLesson = createResource({
url: 'lms.lms.api.delete_lesson',
makeParams(values) {
return {
lesson: values.lesson,
chapter: values.chapter,
}
},
onSuccess() {
outline.reload()
showToast('Success', 'Lesson deleted successfully', 'check')
},
})
const updateLessonIndex = createResource({
url: 'lms.lms.api.update_lesson_index',
makeParams(values) {
return {
lesson: values.lesson,
sourceChapter: values.sourceChapter,
targetChapter: values.targetChapter,
idx: values.idx,
}
},
onSuccess() {
showToast('Success', 'Lesson moved successfully', 'check')
},
})
const trashLesson = (lessonName, chapterName) => {
deleteLesson.submit({
lesson: lessonName,
chapter: chapterName,
})
}
const openChapterDetail = (index) => {
return index == route.params.chapterNumber || index == 1
}
const openChapterModal = (chapter = null) => {
currentChapter.value = chapter
showChapterModal.value = true
}
const getCurrentChapter = () => {
return currentChapter.value
}
const updateOutline = (e) => {
updateLessonIndex.submit({
lesson: e.item.__draggable_context.element.name,
sourceChapter: e.from.dataset.chapter,
targetChapter: e.to.dataset.chapter,
idx: e.newIndex,
})
}
</script>
<style>
.outline-lesson:has(.router-link-active) {
background-color: theme('colors.gray.100');
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<div v-if="reviews.data?.length || membership" class="mt-20 mb-10">
<Button
v-if="membership && !hasReviewed.data"
@click="openReviewModal()"
class="float-right"
>
{{ __('Write a Review') }}
</Button>
<div class="flex items-center font-semibold text-2xl">
{{ __('Student Reviews') }}
</div>
<div class="grid gap-8 mt-10">
<div v-for="(review, index) in reviews.data">
<div class="flex items-center">
<router-link
:to="{
name: 'Profile',
params: { username: review.owner_details.username },
}"
>
<UserAvatar :user="review.owner_details" :size="'2xl'" />
</router-link>
<div class="mx-4">
<router-link
:to="{
name: 'Profile',
params: { username: review.owner_details.username },
}"
>
<span class="text-lg font-medium mr-4">
{{ review.owner_details.full_name }}
</span>
</router-link>
<span>
{{ review.creation }}
</span>
<div class="flex mt-2">
<Star
v-for="index in 5"
class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-2"
:class="
index <= Math.ceil(review.rating)
? 'fill-orange-500'
: 'fill-gray-600'
"
/>
</div>
</div>
</div>
<div v-if="review.review" class="mt-4 leading-5">
{{ review.review }}
</div>
</div>
</div>
</div>
<ReviewModal
v-model="showReviewModal"
v-model:reloadReviews="reviews"
v-model:hasReviewed="hasReviewed"
:courseName="courseName"
/>
</template>
<script setup>
import { Star } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui'
import { computed, ref, inject } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import ReviewModal from '@/components/Modals/ReviewModal.vue'
const user = inject('$user')
const props = defineProps({
courseName: {
type: String,
required: true,
},
avg_rating: {
type: Number,
required: true,
},
membership: {
type: Object,
required: false,
},
})
const hasReviewed = createResource({
url: 'frappe.client.get_count',
cache: ['eligible_to_review', props.courseName, props.membership?.member],
params: {
doctype: 'LMS Course Review',
filters: {
course: props.courseName,
owner: props.membership?.member,
},
},
auto: user.data?.name ? true : false,
})
const reviews = createResource({
url: 'lms.lms.utils.get_reviews',
cache: ['course_reviews', props.courseName],
params: {
course: props.courseName,
},
auto: true,
})
const showReviewModal = ref(false)
function openReviewModal() {
showReviewModal.value = true
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div v-if="course">
<div class="text-xl font-semibold">
{{ course.title }}
</div>
<div v-if="course.chapters.length">
{{ course.chapters }}
</div>
<div v-else class="border bg-white rounded-md p-5 text-center mt-4">
<div>
{{
__(
'There are no chapters in this course. Create and manage chapters from here.'
)
}}
</div>
<Button class="mt-4">
{{ __('Add Chapter') }}
</Button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
course: {
type: Object,
default: {},
},
})
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="relative flex h-full flex-col">
<div class="h-full flex-1">
<div class="flex h-screen text-base">
<div
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
>
<AppSidebar />
</div>
<div class="w-full overflow-auto" id="scrollContainer">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup>
import AppSidebar from './AppSidebar.vue'
</script>

View File

@@ -0,0 +1,244 @@
<template>
<div class="mt-6">
<div v-if="!singleThread" class="flex items-center mb-5">
<Button variant="outline" @click="showTopics = true">
<template #icon>
<ChevronLeft class="w-5 h-5 stroke-1.5 text-gray-700" />
</template>
</Button>
<span class="text-lg font-semibold ml-2">
{{ topic.title }}
</span>
</div>
<div v-for="(reply, index) in replies.data">
<div
class="py-3"
:class="{ 'border-b': index + 1 != replies.data.length }"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<UserAvatar :user="reply.user" class="mr-2" />
<span>
{{ reply.user.full_name }}
</span>
<span class="text-sm ml-2">
{{ timeAgo(reply.creation) }}
</span>
</div>
<Dropdown
v-if="user.data.name == reply.owner && !reply.editable"
:options="[
{
label: 'Edit',
onClick() {
reply.editable = true
},
},
{
label: 'Delete',
onClick() {
deleteReply(reply)
},
},
]"
>
<template v-slot="{ open }">
<MoreHorizontal class="w-4 h-4 stroke-1.5 cursor-pointer" />
</template>
</Dropdown>
<div v-if="reply.editable">
<Button variant="ghost" @click="postEdited(reply)">
{{ __('Post') }}
</Button>
<Button variant="ghost" @click="reply.editable = false">
{{ __('Discard') }}
</Button>
</div>
</div>
<TextEditor
:content="reply.reply"
@change="(val) => (reply.reply = val)"
:editable="reply.editable || false"
:fixedMenu="reply.editable || false"
:editorClass="
reply.editable
? 'ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none'
: 'prose-sm'
"
/>
</div>
</div>
<TextEditor
class="mt-5"
:content="newReply"
:mentions="mentionUsers"
@change="(val) => (newReply = val)"
placeholder="Type your reply here..."
:fixedMenu="true"
editorClass="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none border border-gray-300 rounded-b-md min-h-[7rem] py-1 px-2"
/>
<div class="flex justify-between mt-2">
<span> </span>
<Button @click="postReply()">
<span>
{{ __('Post') }}
</span>
</Button>
</div>
</div>
</template>
<script setup>
import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted, computed } from 'vue'
import { createToast } from '../utils'
const showTopics = defineModel('showTopics')
const newReply = ref('')
const socket = inject('$socket')
const user = inject('$user')
const allUsers = inject('$allUsers')
const props = defineProps({
topic: {
type: Object,
required: true,
},
singleThread: {
type: Boolean,
default: false,
},
})
onMounted(() => {
socket.on('publish_message', (data) => {
replies.reload()
})
socket.on('update_message', (data) => {
replies.reload()
})
socket.on('delete_message', (data) => {
replies.reload()
})
})
const replies = createResource({
url: 'lms.lms.utils.get_discussion_replies',
cache: ['replies', props.topic],
makeParams(values) {
return {
topic: props.topic.name,
}
},
auto: true,
})
const newReplyResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Reply',
reply: newReply.value,
topic: props.topic.name,
},
}
},
})
const mentionUsers = computed(() => {
let users = Object.values(allUsers.data).map((user) => {
return {
value: user.name,
label: user.full_name,
}
})
return users
})
const postReply = () => {
newReplyResource.submit(
{},
{
validate() {
if (!newReply.value) {
return 'Reply cannot be empty'
}
},
onSuccess() {
newReply.value = ''
replies.reload()
},
onError(err) {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-red-600 text-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
},
}
)
}
const editReplyResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Discussion Reply',
name: values.name,
fieldname: 'reply',
value: values.reply,
}
},
})
const postEdited = (reply) => {
editReplyResource.submit(
{
name: reply.name,
reply: reply.reply,
},
{
validate() {
if (!reply.reply) {
return 'Reply cannot be empty'
}
},
onSuccess() {
reply.editable = false
replies.reload()
},
}
)
}
const deleteReplyResource = createResource({
url: 'frappe.client.delete',
makeParams(values) {
return {
doctype: 'Discussion Reply',
name: values.name,
}
},
})
const deleteReply = (reply) => {
deleteReplyResource.submit(
{
name: reply.name,
},
{
onSuccess() {
replies.reload()
},
}
)
}
</script>

View File

@@ -0,0 +1,151 @@
<template>
<div>
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
{{ __('New {0}').format(title) }}
</Button>
<div class="text-xl font-semibold">
{{ __(title) }}
</div>
</div>
<div v-if="topics.data?.length && !singleThread">
<div v-if="showTopics" v-for="(topic, index) in topics.data">
<div
@click="showReplies(topic)"
class="flex items-center cursor-pointer py-5 w-full"
:class="{ 'border-b': index + 1 != topics.data.length }"
>
<UserAvatar :user="topic.user" size="2xl" class="mr-4" />
<div>
<div class="text-lg font-semibold mb-1">
{{ topic.title }}
</div>
<div class="flex items-center">
<span>
{{ topic.user.full_name }}
</span>
<span class="text-sm ml-3">
{{ timeAgo(topic.creation) }}
</span>
</div>
</div>
</div>
</div>
<div v-else>
<DiscussionReplies
:topic="currentTopic"
v-model:showTopics="showTopics"
/>
</div>
</div>
<div v-else-if="singleThread && topics.data">
<DiscussionReplies :topic="topics.data" :singleThread="singleThread" />
</div>
<div
v-else
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-gray-500 stroke-1.5 mr-2" />
<div class="">
<div v-if="emptyStateTitle" class="font-medium mb-2">
{{ __(emptyStateTitle) }}
</div>
<div class="text-gray-600">
{{ __(emptyStateText) }}
</div>
</div>
</div>
<DiscussionModal
v-model="showTopicModal"
:title="__('New {0}').format(title)"
:doctype="props.doctype"
:docname="props.docname"
v-model:reloadTopics="topics"
/>
</template>
<script setup>
import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue'
import { timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
import { MessageSquareText } from 'lucide-vue-next'
import { getScrollContainer } from '@/utils/scrollContainer'
const showTopics = ref(true)
const currentTopic = ref(null)
const socket = inject('$socket')
const user = inject('$user')
const showTopicModal = ref(false)
const props = defineProps({
title: {
type: String,
required: true,
},
doctype: {
type: String,
required: true,
},
docname: {
type: String,
required: true,
},
emptyStateTitle: {
type: String,
default: '',
},
emptyStateText: {
type: String,
default: 'Start a discussion',
},
singleThread: {
type: Boolean,
default: false,
},
scrollToBottom: {
type: Boolean,
default: false,
},
})
onMounted(() => {
if (user.data) topics.reload()
socket.on('new_discussion_topic', (data) => {
topics.refresh()
})
if (props.scrollToBottom) {
setTimeout(() => {
scrollToEnd()
}, 100)
}
})
const scrollToEnd = () => {
let scrollContainer = getScrollContainer()
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
const topics = createResource({
url: 'lms.lms.utils.get_discussion_topics',
cache: ['topics', props.doctype, props.docname],
makeParams() {
return {
doctype: props.doctype,
docname: props.docname,
single_thread: props.singleThread,
}
},
})
const showReplies = (topic) => {
showTopics.value = false
currentTopic.value = topic
}
const openTopicModal = () => {
showTopicModal.value = true
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1584_1676)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.17474 0.625C2.34632 0.625 1.67474 1.29657 1.67474 2.125V7.475C1.67474 8.30343 2.34632 8.975 3.17474 8.975H14.8247C15.6532 8.975 16.3247 8.30343 16.3247 7.475V2.125C16.3247 1.29657 15.6532 0.625 14.8247 0.625H3.17474ZM2.67474 2.125C2.67474 1.84886 2.8986 1.625 3.17474 1.625H14.8247C15.1009 1.625 15.3247 1.84886 15.3247 2.125V7.475C15.3247 7.75114 15.1009 7.975 14.8247 7.975H3.17474C2.8986 7.975 2.67474 7.75114 2.67474 7.475V2.125ZM4.27478 10.0749C3.99864 10.0749 3.77478 10.2987 3.77478 10.5749V12.6749C3.77478 12.951 3.99864 13.1749 4.27478 13.1749C4.55092 13.1749 4.77478 12.951 4.77478 12.6749V11.0749H6.92478V12.6749C6.92478 12.951 7.14864 13.1749 7.42478 13.1749C7.70092 13.1749 7.92478 12.951 7.92478 12.6749V10.5749C7.92478 10.2987 7.70092 10.0749 7.42478 10.0749H4.27478ZM10.0749 10.5749C10.0749 10.2987 10.2987 10.0749 10.5749 10.0749H13.7249C14.001 10.0749 14.2249 10.2987 14.2249 10.5749V12.6749C14.2249 12.951 14.001 13.1749 13.7249 13.1749C13.4487 13.1749 13.2249 12.951 13.2249 12.6749V11.0749H11.0749V12.6749C11.0749 12.951 10.851 13.1749 10.5749 13.1749C10.2987 13.1749 10.0749 12.951 10.0749 12.6749V10.5749ZM1.125 14.275C0.848858 14.275 0.625 14.4988 0.625 14.775V16.875C0.625 17.1511 0.848858 17.375 1.125 17.375C1.40114 17.375 1.625 17.1511 1.625 16.875V15.275H3.775V16.875C3.775 17.1511 3.99886 17.375 4.275 17.375C4.55114 17.375 4.775 17.1511 4.775 16.875V14.775C4.775 14.4988 4.55114 14.275 4.275 14.275H1.125ZM13.2252 14.775C13.2252 14.4988 13.4491 14.275 13.7252 14.275H16.8752C17.1514 14.275 17.3752 14.4988 17.3752 14.775V16.875C17.3752 17.1511 17.1514 17.375 16.8752 17.375C16.5991 17.375 16.3752 17.1511 16.3752 16.875V15.275H14.2252V16.875C14.2252 17.1511 14.0014 17.375 13.7252 17.375C13.4491 17.375 13.2252 17.1511 13.2252 16.875V14.775ZM7.42511 14.275C7.14897 14.275 6.92511 14.4988 6.92511 14.775V16.875C6.92511 17.1511 7.14897 17.375 7.42511 17.375C7.70125 17.375 7.92511 17.1511 7.92511 16.875V15.275H10.0751V16.875C10.0751 17.1511 10.299 17.375 10.5751 17.375C10.8513 17.375 11.0751 17.1511 11.0751 16.875V14.775C11.0751 14.4988 10.8513 14.275 10.5751 14.275H7.42511Z"
fill="#525252"
/>
</g>
<defs>
<clipPath id="clip0_1584_1676">
<rect width="18" height="18" fill="white" />
</clipPath>
</defs>
</svg>
</template>

View File

@@ -0,0 +1,27 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.875 9.06223L3 9.06232"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6.74537 5.31699L3 9.06236L6.74527 12.8076"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14.1423 4L14.1423 14.125"
stroke="currentColor"
stroke-linecap="round"
/>
</svg>
</template>

View File

@@ -0,0 +1,18 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="8"
cy="8"
r="4.5"
fill="transparent"
stroke="currentColor"
stroke-width="3"
/>
</svg>
</template>

View File

@@ -0,0 +1,36 @@
<template>
<svg
width="118"
height="118"
viewBox="0 0 118 118"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M93.9278 0H23.1013C10.3428 0 0 10.3428 0 23.1013V93.9278C0 106.686 10.3428 117.029 23.1013 117.029H93.9278C106.686 117.029 117.029 106.686 117.029 93.9278V23.1013C117.029 10.3428 106.686 0 93.9278 0Z"
fill="url(#paint0_radial_174_336)"
/>
<path
d="M93.9278 0H23.1013C10.3428 0 0 10.3428 0 23.1013V93.9278C0 106.686 10.3428 117.029 23.1013 117.029H93.9278C106.686 117.029 117.029 106.686 117.029 93.9278V23.1013C117.029 10.3428 106.686 0 93.9278 0Z"
fill="#0B3D3D"
fill-opacity="0.8"
/>
<path
d="M95.1879 33.1294L91.4077 32.0268C80.1721 28.7716 67.9389 30.9242 58.5409 37.7496C52.083 33.0769 43.9975 30.5042 36.1746 30.5042H21.8938V41.0048H36.2796C42.2649 41.0048 48.1978 42.9999 52.923 46.6226L58.5934 50.9279L64.2637 46.6226C70.144 42.1599 77.5469 40.2698 84.7923 41.2673V76.1818C75.5518 75.2367 66.2063 77.7044 58.6459 83.2172C51.0854 77.7044 41.6349 75.2367 32.4994 76.1818V52.8705H21.9988V86.4724H95.3454V33.1294H95.1879Z"
fill="#58FF9B"
/>
<defs>
<radialGradient
id="paint0_radial_174_336"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(117.24 -101.5) rotate(105.042) scale(226.282)"
>
<stop offset="0.445162" stop-color="#1F7676" />
<stop offset="1" stop-color="#0A4B4B" />
</radialGradient>
</defs>
</svg>
</template>

View File

@@ -0,0 +1,77 @@
<template>
<div class="flex rounded p-1 lg:px-2 lg:py-2.5 hover:bg-gray-100">
<div class="flex w-3/5 md:w-2/5">
<img
:src="job.company_logo"
class="w-12 h-12 rounded-lg object-contain mr-4"
:alt="job.company_name"
/>
<div>
<div class="font-medium mb-1">
{{ job.job_title }}
</div>
<div class="text-gray-700">
{{ job.company_name }}
</div>
</div>
</div>
<div class="flex justify-end w-1/5 text-gray-700">
{{ job.location.replace(',', '').split(' ')[0] }}
</div>
<div
class="flex justify-end w-1/5 text-gray-700 text-right hidden md:block"
>
{{ job.type }}
</div>
<div class="flex justify-end w-1/5 text-sm text-gray-700 text-right">
{{ dayjs(job.creation).format('DD MMM YYYY') }}
</div>
</div>
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
<div class="flex justify-between">
<div>
<div class="text-xl font-semibold mb-2">
{{ job.job_title }}
</div>
<div>
{{ __("posted by") }}
<span class="font-medium">
{{ job.company_name }}
</span>
</div>
</div>
<img
:src="job.company_logo"
class="w-12 h-12 rounded-lg object-contain"
/>
</div>
<div class="flex justify-between mt-8">
<div class="flex items-center">
<Badge :label="job.type" theme="green" size="lg" class="mr-4"/>
<Badge :label="job.location" theme="gray" size="lg">
<template #prefix>
<MapPin class="h-4 w-4 stroke-1.5" />
</template>
</Badge>
</div>
<div>
<span class="font-medium">
{{ dayjs(job.creation).format('DD MMM YYYY') }}
</span>
</div>
</div>
</div> -->
</template>
<script setup>
import { MapPin } from 'lucide-vue-next'
import { Badge } from 'frappe-ui'
import { inject } from 'vue'
const dayjs = inject('$dayjs')
const props = defineProps({
job: {
type: Object,
default: null,
},
})
</script>

View File

@@ -0,0 +1,104 @@
<template>
<div v-if="youtube">
<iframe
class="youtube-video"
:src="getYouTubeVideoSource(youtube.split('/').pop())"
width="100%"
height="400"
frameborder="0"
allowfullscreen
></iframe>
</div>
<div v-for="block in content.split('\n\n')">
<div v-if="block.includes('{{ YouTubeVideo')">
<iframe
class="youtube-video"
:src="getYouTubeVideoSource(block)"
width="100%"
height="400"
frameborder="0"
allowfullscreen
></iframe>
</div>
<div v-else-if="block.includes('{{ Quiz')">
<Quiz :quiz="getId(block)" />
</div>
<div v-else-if="block.includes('{{ Video')">
<video
controls
width="100%"
controlsList="nodownload"
oncontextmenu="return false;"
>
<source :src="getId(block)" type="video/mp4" />
</video>
</div>
<div v-else-if="block.includes('{{ PDF')">
<iframe
:src="getPDFSource(block)"
width="100%"
height="400"
frameborder="0"
allowfullscreen
></iframe>
</div>
<div v-else-if="block.includes('{{ Audio')">
<audio width="100%" controls controlsList="nodownload">
<source :src="getId(block)" type="audio/mp3" />
</audio>
</div>
<div v-else-if="block.includes('{{ Embed')">
<iframe
width="100%"
height="400"
:src="getId(block)"
frameborder="0"
allowfullscreen
>
</iframe>
</div>
<div v-else v-html="markdown.render(block)"></div>
</div>
<div v-if="quizId">
<Quiz :quiz="quizId" />
</div>
</template>
<script setup>
import Quiz from '@/components/QuizBlock.vue'
import MarkdownIt from 'markdown-it'
const markdown = new MarkdownIt({
html: true,
linkify: true,
})
const props = defineProps({
content: {
type: String,
required: true,
},
youtube: {
type: String,
required: false,
},
quizId: {
type: String,
required: false,
},
})
const getYouTubeVideoSource = (block) => {
if (block.includes('{{')) {
block = getId(block)
}
return `https://www.youtube.com/embed/${block}`
}
const getPDFSource = (block) => {
return `${getId(block)}#toolbar=0`
}
const getId = (block) => {
return block.match(/\(["']([^"']+?)["']\)/)[1]
}
</script>

View File

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

View File

@@ -0,0 +1,111 @@
<template>
<Button
v-if="user.data.is_moderator"
variant="solid"
class="float-right mb-5"
@click="openLiveClassModal"
>
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add Live Class') }}
</span>
</Button>
<div class="text-lg font-semibold mb-5">
{{ __('Live Class') }}
</div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full p-3"
>
<div class="font-semibold text-lg mb-4">
{{ cls.title }}
</div>
<div class="mb-4">
{{ cls.description }}
</div>
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-5">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(cls.time) }}
</span>
</div>
<div class="flex items-center space-x-2 mt-auto">
<a
:href="cls.start_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
</div>
</div>
<div v-else class="text-sm italic text-gray-600">
{{ __('No live classes scheduled') }}
</div>
<LiveClassModal
:batch="props.batch"
v-model="showLiveClassModal"
v-model:reloadLiveClasses="liveClasses"
/>
</template>
<script setup>
import { createListResource, Button } from 'frappe-ui'
import { Plus, Clock, Calendar, Video, Monitor } from 'lucide-vue-next'
import { inject } from 'vue'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import { ref } from 'vue'
import { formatTime } from '@/utils/'
const user = inject('$user')
const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs')
const props = defineProps({
batch: {
type: String,
required: true,
},
})
const liveClasses = createListResource({
doctype: 'LMS Live Class',
filters: {
batch_name: props.batch,
date: ['>=', new Date()],
},
fields: [
'title',
'description',
'time',
'date',
'start_url',
'join_url',
'owner',
],
orderBy: 'date',
auto: true,
})
const openLiveClassModal = () => {
showLiveClassModal.value = true
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div class="flex h-full flex-col">
<div class="h-full pb-10" id="scrollContainer">
<slot />
</div>
<div
v-if="tabs"
class="fixed flex justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
:style="{
gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))`,
}"
>
<button
v-for="tab in tabs"
:key="tab.label"
:class="isVisible(tab) ? 'block' : 'hidden'"
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
@click="handleClick(tab)"
>
<component
:is="icons[tab.icon]"
class="h-6 w-6 stroke-1.5"
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
/>
</button>
</div>
</div>
</template>
<script setup>
import { getSidebarLinks } from '../utils'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user'
import * as icons from 'lucide-vue-next'
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const router = useRouter()
let { userResource } = usersStore()
const tabs = computed(() => {
let links = getSidebarLinks()
if (user) {
links.push({
label: 'Profile',
icon: 'UserRound',
activeFor: [
'Profile',
'ProfileAbout',
'ProfileCertification',
'ProfileEvaluator',
'ProfileRoles',
],
})
links.push({
label: 'Log out',
icon: 'LogOut',
})
} else {
links.push({
label: 'Log in',
icon: 'LogIn',
})
}
return links
})
let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name)
}
const handleClick = (tab) => {
if (tab.label == 'Log in') window.location.href = '/login'
else if (tab.label == 'Log out')
logout.submit().then(() => {
isLoggedIn = false
})
else if (tab.label == 'Profile')
router.push({
name: 'Profile',
params: {
username: userResource.data?.username,
},
})
else router.push({ name: tab.to })
}
const isVisible = (tab) => {
if (tab.label == 'Log in') return !isLoggedIn
else if (tab.label == 'Log out') return isLoggedIn
else return true
}
</script>

View File

@@ -0,0 +1,117 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Make an Announcement'),
size: 'xl',
actions: [
{
label: 'Submit',
variant: 'solid',
onClick: (close) => makeAnnouncement(close),
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<div class="">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Subject') }}
</div>
<Input type="text" v-model="announcement.subject" />
</div>
<div class="">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Reply To') }}
</div>
<Input type="text" v-model="announcement.replyTo" />
</div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Announcement') }}
</div>
<TextEditor
:bubbleMenu="true"
@change="(val) => (announcement.announcement = val)"
editorClass="prose-sm py-2 px-2 min-h-[200px] border-gray-300 hover:border-gray-400 rounded-md bg-gray-200"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
import { reactive } from 'vue'
import { createToast } from '@/utils/'
const show = defineModel()
const props = defineProps({
batch: {
type: String,
required: true,
},
students: {
type: Array,
required: true,
},
})
const announcement = reactive({
subject: '',
replyTo: '',
announcement: '',
})
const announcementResource = createResource({
url: 'frappe.core.doctype.communication.email.make',
makeParams(values) {
return {
recipients: props.students.join(', '),
cc: announcement.replyTo,
subject: announcement.subject,
content: announcement.announcement,
doctype: 'LMS Batch',
name: props.batch,
send_email: 1,
}
},
})
const makeAnnouncement = (close) => {
announcementResource.submit(
{},
{
validate() {
if (!props.students.length) {
return 'No students in this batch'
}
if (!announcement.subject) {
return 'Subject is required'
}
},
onSuccess() {
close()
createToast({
title: 'Success',
text: 'Announcement has been sent successfully',
icon: 'Check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
},
onError(err) {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-red-600 text-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
},
}
)
}
</script>

View File

@@ -0,0 +1,77 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add a course'),
size: 'sm',
actions: [
{
label: __('Submit'),
variant: 'solid',
onClick: (close) => addCourse(close),
},
],
}"
>
<template #body-content>
<Link doctype="LMS Course" v-model="course" :label="__('Course')" />
<Link
doctype="Course Evaluator"
v-model="evaluator"
:label="__('Evaluator')"
class="mt-4"
/>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import { ref, defineModel } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
const show = defineModel()
const course = ref(null)
const evaluator = ref(null)
const courses = defineModel('courses')
const props = defineProps({
batch: {
type: String,
default: null,
},
})
const createBatchCourse = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Batch Course',
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'courses',
course: course.value,
evaluator: evaluator.value,
},
}
},
})
const addCourse = (close) => {
createBatchCourse.submit(
{},
{
onSuccess() {
courses.value.reload()
close()
course.value = null
evaluator.value = null
},
onError(err) {
showToast('Error', err.message[0] || err, 'x')
},
}
)
}
</script>

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