Compare commits

...

257 Commits

Author SHA1 Message Date
Hussain Nagaria
d9bf4e2c58 fix: title and tab copy 2024-04-16 14:42:17 +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
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
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
2905a6af1a Merge pull request #749 from pateljannat/batch-conditions
fix: batch registration button conditions
2024-03-07 10:33:39 +05:30
Jannat Patel
4cc27adb8b fix: batch registration button conditions 2024-03-07 10:21:18 +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
a5d000f702 fix: quix show correct answers 2024-02-21 17:02:18 +05:30
Jannat Patel
5317aa8fb5 feat: job application 2024-02-21 12:08:05 +05:30
Jannat Patel
39aa1d443d fix: removed unnecessary start learning buttons 2024-02-19 17:07:22 +05:30
Jannat Patel
36c4e2f4dc feat: job application modal 2024-02-19 16:54:07 +05:30
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
4b4086afb3 fix: active site capture 2024-02-07 11:14:12 +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
b0bb7d32ca chore: capture active sites 2024-02-06 10:12:49 +05:30
Jannat Patel
ff1bd91223 feat: cohort description 2024-02-05 23:06:26 +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
3dad3580bb fix: batch self enrollment 2024-02-01 10:52:23 +05:30
Jannat Patel
53eb95612c fix: review button 2024-02-01 10:48:05 +05:30
Jannat Patel
8f687145be fix: pricing issue 2024-01-25 23:13:43 +05:30
Jannat Patel
9c405edd09 Merge branch 'main' of https://github.com/frappe/lms 2024-01-25 15:45:41 +05:30
Jannat Patel
fe791dc478 fix: batch self enrollment 2024-01-25 15:45:27 +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
42417621fa Merge pull request #712 from IslemMedjahdi/patch-1
Update docker-installation.md
2024-01-23 13:30:10 +05:30
Jannat Patel
d3b3d85c84 fix: redirect to login if guest before batch enrollment 2024-01-22 11:19:32 +05:30
Md Hussain Nagaria
b700013704 fix: only show start date for single day batches (#726) 2024-01-22 10:55:36 +05:30
Jannat Patel
bac229c731 Merge pull request #725 from pateljannat/students-in-batch
feat: self enrolment in batches
2024-01-22 10:54:20 +05:30
Jannat Patel
28043e634b fix: removed unnecessary roles 2024-01-22 10:43:02 +05:30
Md Hussain Nagaria
b672108155 fix: remove $ typos (#723) 2024-01-22 10:33:55 +05:30
Jannat Patel
5e569ab0e6 feat: self enrollment in batches 2024-01-19 23:56:48 +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
43a07e53a6 Merge branch 'main' of https://github.com/frappe/lms 2024-01-18 11:32:12 +05:30
Jannat Patel
fbd83196fc fix: job page logos 2024-01-18 11:31:50 +05:30
Jannat Patel
465f4e1e96 docs: fixed readme logo dimensions 2024-01-17 14:48:48 +05:30
Jannat Patel
43d409ce64 Merge branch 'main' of https://github.com/frappe/lms 2024-01-17 14:47:11 +05:30
Jannat Patel
a5fc52ec29 fix: logo on readme 2024-01-17 14:46:48 +05:30
Jannat Patel
a9b06575d0 Merge pull request #718 from pateljannat/change-jobs-route
fix: changed jobs route to job-openings
2024-01-17 11:24:06 +05:30
Jannat Patel
3070cbed3c fix: changed jobs route to job-openings 2024-01-17 11:00:31 +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
Medjahdi Islem
d712881e16 Update docker-installation.md 2024-01-01 15:32:00 +01:00
Jannat Patel
991dc7f8c8 fix: batch card 2023-12-28 12:04:06 +05:30
Jannat Patel
7087fde686 feat: quiz base 2023-12-28 11:59:44 +05:30
Jannat Patel
6b6c8da785 Merge pull request #706 from pateljannat/evaluation-request-validations
fix: evaluation dates validation
2023-12-22 22:54:23 +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
f40fbaed3e fix: check country from ip for multicurrency 2023-12-21 14:22:58 +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
4973386dd0 Merge pull request #710 from pateljannat/usd-pricing
feat: usd pricing
2023-12-21 12:58:06 +05:30
Jannat Patel
13536b8bad feat: usd pricing 2023-12-21 12:28:11 +05:30
Jannat Patel
caea7e334c Merge pull request #707 from pateljannat/batch-meta-image
fix: batch meta image saving
2023-12-21 09:56:50 +05:30
Jannat Patel
4065b1b8cc feat: review modal 2023-12-21 09:56:15 +05:30
Jannat Patel
b248774774 Merge pull request #708 from pateljannat/confirmation-email
fix: copy of enrolment email
2023-12-20 12:16:03 +05:30
Jannat Patel
7a9d6325d5 fix: copy of enrollment email 2023-12-20 11:49:32 +05:30
Jannat Patel
b0d0b41502 fix: batch meta image saving 2023-12-20 11:37:54 +05:30
Jannat Patel
30c89cb13c fix: evaluation dates validation 2023-12-20 11:00:09 +05:30
Jannat Patel
eb3afbbad1 feat: rating component 2023-12-20 10:35:12 +05:30
Jannat Patel
9175737b9c Merge pull request #705 from pateljannat/email-address-assignment-issue
fix: assignment issue
2023-12-19 11:49:53 +05:30
Jannat Patel
7ae772205a Merge pull request #704 from pateljannat/evaluation-fields
fix: evaluation fields
2023-12-19 11:15:32 +05:30
Jannat Patel
00b0a20c83 fix: translations 2023-12-19 10:54:56 +05:30
Jannat Patel
6604866342 fix: assignment issue 2023-12-19 10:51:36 +05:30
Jannat Patel
881c3d943a fix: evaluation fields 2023-12-18 20:06:43 +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
d5118cc91f fix: razorpay payment in other currency 2023-12-14 16:08:07 +05:30
Jannat Patel
ac74cbdf72 fix: razorpay payment in other currency 2023-12-14 15:53:07 +05:30
Jannat Patel
01f7fc3cff fix: razorpay payment in other currency 2023-12-14 15:12:23 +05:30
Jannat Patel
85c850e5bf fix: cohorts 2023-12-14 15:08:42 +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
67dfffdd58 fix: calendar day view range 2023-12-13 10:41:15 +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
ae4aadb8d3 Merge pull request #702 from pateljannat/timetable-mobile-view
feat: day view for timetable on mobile
2023-12-12 12:15:02 +05:30
Jannat Patel
e5dc2bad6a feat: day view for timetable on mobile 2023-12-12 12:00:24 +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
0e2fabf139 Merge pull request #700 from pateljannat/fix-milestones
fix: future date access on timetables for moderators
2023-12-08 16:06:38 +05:30
Jannat Patel
c45a372e83 fix: future date access on timetables for moderators 2023-12-08 15:27:33 +05:30
Jannat Patel
25f24b98c6 feat: fetch translations 2023-12-08 15:04:41 +05:30
Jannat Patel
98ecb4c27c Merge pull request #699 from pateljannat/all-day-events-style
fix: All day events style
2023-12-07 17:37:44 +05:30
Jannat Patel
9023094326 fix: timetable style 2023-12-07 16:00:11 +05:30
Jannat Patel
497de05db2 fix: all day events style 2023-12-07 15:53:10 +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
cb3224664e Merge pull request #695 from pateljannat/all-day-events
feat: all day events
2023-11-30 12:12:09 +05:30
Jannat Patel
9b532a5470 fix: editing evaluation end date from lms portal 2023-11-30 12:02:43 +05:30
Jannat Patel
f1f9d9790b feat: all day events 2023-11-30 11:51:36 +05:30
Jannat Patel
96190910a7 Merge pull request #694 from pateljannat/evaluation-end-date
feat: evaluation end date
2023-11-29 22:58:53 +05:30
Jannat Patel
6484763d37 fix: removed additional roles 2023-11-29 22:32:52 +05:30
Jannat Patel
0e2feac81e fix: frappe-ui setup 2023-11-29 22:28:38 +05:30
Jannat Patel
6f1e7624ec feat:evaluation end date from lms portal 2023-11-29 18:01:44 +05:30
Jannat Patel
eef5bd6062 feat: evaluation end date 2023-11-29 17:36:34 +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
Md Hussain Nagaria
de60fbb25a fix(LMS Batch): add portal web link to form view (#692) 2023-11-27 22:19:07 +05:30
Jannat Patel
fd9a638879 fix: quiz and timetable issues 2023-11-24 12:37:34 +05:30
Jannat Patel
ddcb718a3a fix: quiz submission questions 2023-11-23 11:50:22 +05:30
Jannat Patel
a17a7453e7 fix: ceil the percentage 2023-11-22 17:49:47 +05:30
Jannat Patel
479be0b8ee Merge pull request #681 from pateljannat/notify-mentions
feat: misc changes
2023-11-22 17:34:44 +05:30
Jannat Patel
6f40c357b3 fix: quiz submission page rendering 2023-11-22 17:27:42 +05:30
Jannat Patel
81db6c544d fix: mention email link 2023-11-22 13:04:04 +05:30
Jannat Patel
be4e3aa963 Merge pull request #682 from pateljannat/course-creation-error
fix: course creation issue
2023-11-21 14:38:27 +05:30
Jannat Patel
6da0c07a3d fix: course creation issue 2023-11-21 14:13:11 +05:30
Jannat Patel
b4ad10ca35 Merge pull request #680 from pateljannat/lesson-creation-issue
fix: encode chapter during lesson creation
2023-11-21 13:46:07 +05:30
Jannat Patel
2388b878dc fix: encode chapter during lesson creation 2023-11-21 13:35:47 +05:30
Jannat Patel
8cdaa7877a feat: discussions mention notifications 2023-11-21 13:32:12 +05:30
Jannat Patel
d314287883 Merge pull request #679 from pateljannat/upcoming-batch
fix: upcoming batches based on start time
2023-11-17 11:00:07 +05:30
Jannat Patel
b70dfc8e82 fix: upcoming batches based on start time 2023-11-17 10:45:29 +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
a5a7184f9a Merge pull request #676 from pateljannat/pyproject
build: added pyproject.toml
2023-11-09 15:54:12 +05:30
Jannat Patel
4e019d0a43 Merge pull request #677 from pateljannat/get-certificate-issue
fix: get certificate button visibility
2023-11-09 15:50:35 +05:30
Jannat Patel
8453b54360 fix: syntax of pyproject 2023-11-09 15:46:51 +05:30
Jannat Patel
9f9dfdb26d fix: get certificate button visibility 2023-11-09 15:27:46 +05:30
Jannat Patel
9fd4984247 fix: get certificate button visibility 2023-11-09 15:22:05 +05:30
Jannat Patel
9ebd64f47d build: added pyproject.toml 2023-11-09 15:18:06 +05:30
Jannat Patel
4316a37ed6 Merge pull request #675 from pateljannat/certificate-fix
fix: certificates in profile
2023-11-09 12:58:47 +05:30
Jannat Patel
2d745460e8 fix: certificates in profile 2023-11-09 12:29:23 +05:30
Jannat Patel
b5258b6d9f Merge pull request #674 from pateljannat/assignment-fix
fix: assignment submission email
2023-11-07 12:05:21 +05:30
Jannat Patel
41b076c0db fix: user type filter on assignment submission 2023-11-07 11:42:31 +05:30
Jannat Patel
9d65e5e398 Merge pull request #673 from rtdany10/total-mark-issue
fix: mandatory total marks issue
2023-11-07 11:41:34 +05:30
Jannat Patel
7250bf7d65 fix: changed recipient in assignment submission email 2023-11-07 11:32:26 +05:30
Jannat Patel
4d7b247378 fix: assignment submission email 2023-11-07 11:31:58 +05:30
Dany Robert
0aaa58cd54 fix: mandatory total marks issue 2023-11-07 04:48:32 +00:00
Jannat Patel
014b85f12c Merge pull request #672 from pateljannat/user-category-default
fix: default user category
2023-11-06 19:14:19 +05:30
Jannat Patel
929f97cb72 test: skip invite email if in test 2023-11-06 19:08:30 +05:30
Jannat Patel
de9cb935ee test: skip certificate email if in test 2023-11-06 18:58:12 +05:30
Jannat Patel
9aafc176e4 fix: default user category 2023-11-06 18:39:52 +05:30
Jannat Patel
0488ae8305 Merge pull request #670 from pateljannat/cert-fixes
fix: certificate border and email
2023-11-02 12:29:55 +05:30
Jannat Patel
60fd317d98 feat: certification email 2023-11-02 12:20:11 +05:30
Jannat Patel
e54435d85d fix: certificate border 2023-11-01 18:27:17 +05:30
Jannat Patel
3a23b91c90 Merge pull request #669 from pateljannat/batch-tabs-customisation
feat: batch tabs settings
2023-10-30 18:42:56 +05:30
Jannat Patel
69591577bf feat: batch tabs settings 2023-10-30 18:30:58 +05:30
Jannat Patel
e56afba6d3 Merge pull request #645 from tundebabzy/644
fix: 10th lesson access issue
2023-10-27 18:45:00 +05:30
Jannat Patel
98536ce4c7 Merge pull request #668 from frappe/pateljannat-security-md
chore: created security policy
2023-10-27 18:36:39 +05:30
Jannat Patel
05282178dd fix: removed functional programing code 2023-10-27 18:30:45 +05:30
Jannat Patel
1af547288c chore: fix linters 2023-10-27 17:53:35 +05:30
Jannat Patel
b4af82acbc chore: fix linters 2023-10-27 17:21:28 +05:30
Jannat Patel
50fbe00d23 Merge pull request #667 from pateljannat/batch-ic
fix: misc batch issues
2023-10-27 17:18:20 +05:30
Jannat Patel
b44428677e chore: created security policy 2023-10-27 17:17:10 +05:30
Tunde Akinyanmi
d67faa1610 forgot to remove the LessonBookmark class 2023-10-27 12:05:37 +01:00
Tunde Akinyanmi
7b3f4c29d8 remove LessonBookmark abstraction. 2023-10-27 12:00:44 +01:00
Jannat Patel
a49871c5b1 fix: misc batch issues 2023-10-27 16:04:03 +05:30
Jannat Patel
e4005792af Merge pull request #665 from saadchaudharry/main
Fix:timetable validation
2023-10-27 12:04:36 +05:30
saadindictrans
8c0c09a21b Fix:timetable validation 2023-10-27 11:33:18 +05:30
Jannat Patel
a9b05f4256 Merge pull request #662 from pateljannat/batch-source
feat: batch source
2023-10-26 20:35:57 +05:30
Jannat Patel
cb6013a7a6 fix: source doctype name during install 2023-10-26 18:09:05 +05:30
Jannat Patel
bb23b78a4f fix: made source mandatory in billing form 2023-10-26 18:00:11 +05:30
Jannat Patel
243277012f feat: batch source 2023-10-26 17:51:43 +05:30
Jannat Patel
c9ed8a4b03 Merge pull request #661 from pateljannat/fix-eval-slot
fix: evaluation slots
2023-10-26 15:25:55 +05:30
Jannat Patel
d413acaef3 fix: evaluation slots 2023-10-26 15:14:35 +05:30
Jannat Patel
d6aad6cd74 Merge branch 'main' into 644 2023-10-26 14:45:56 +05:30
Jannat Patel
ca45e43003 Merge pull request #658 from pateljannat/certification-fix
fix: certificate download template
2023-10-26 12:52:14 +05:30
Jannat Patel
ad39530705 fix: certificate download template 2023-10-26 11:30:26 +05:30
Jannat Patel
a6c2378b56 fix: certificate template pathc 2023-10-25 14:25:42 +05:30
Jannat Patel
c073d2201d Merge pull request #657 from pateljannat/course-certificates
feat: certificate template
2023-10-25 14:15:54 +05:30
Jannat Patel
6d70de2eb1 feat: certificate template 2023-10-25 13:08:56 +05:30
Jannat Patel
48982e8f4a Merge pull request #655 from pateljannat/append-student-email
fix: Append student email to batch
2023-10-25 11:47:54 +05:30
Jannat Patel
397128f980 chore: merged conflicts 2023-10-25 11:36:07 +05:30
Jannat Patel
1d77fd3f94 Merge pull request #654 from pateljannat/timetable-milestones
feat: timetable milestones
2023-10-25 11:28:56 +05:30
Jannat Patel
60e78e8e74 feat: emails tab 2023-10-23 20:12:48 +05:30
Jannat Patel
4a9ccc6fde fix: cc in student email 2023-10-23 19:14:59 +05:30
Jannat Patel
a707095fae fix: link student emails to batch 2023-10-23 19:11:36 +05:30
Jannat Patel
d4f662f65e feat: timetable milestones 2023-10-23 16:35:35 +05:30
Jannat Patel
509b1365d9 Merge pull request #646 from pateljannat/quiz-refactor
feat: Quiz Refactor
2023-10-20 11:31:31 +05:30
Jannat Patel
d0b236e381 fix: translations 2023-10-20 11:08:10 +05:30
Jannat Patel
fe98265636 test: fix quiz tests 2023-10-20 10:56:47 +05:30
Jannat Patel
3f7d1b1e83 feat: add questions from LMS Portal 2023-10-19 22:15:30 +05:30
Jannat Patel
52cde329c1 Merge branch 'quiz-refactor' of https://github.com/pateljannat/lms into quiz-refactor 2023-10-18 23:21:57 +05:30
Jannat Patel
68b2dd6147 feat: quiz question from UI 2023-10-18 23:21:49 +05:30
Jannat Patel
5fa0d022dc Merge branch 'main' into quiz-refactor 2023-10-18 15:47:58 +05:30
Jannat Patel
d996a5c53f Merge pull request #649 from NagariaHussain/feat-reply-to
feat: add reply_to in email students dialog
2023-10-18 12:35:46 +05:30
Jannat Patel
b6dfc6ed4d Merge pull request #650 from NagariaHussain/feat-payment-info
feat: store batch/course in LMS Payment
2023-10-18 12:35:33 +05:30
Jannat Patel
c7c2ba83f3 Merge branch 'main' into feat-payment-info 2023-10-18 12:12:19 +05:30
Hussain Nagaria
2bffabff05 style: fix linter 2023-10-18 12:11:23 +05:30
Hussain Nagaria
697e81df10 feat: add reply_to in email students 2023-10-18 12:11:23 +05:30
Jannat Patel
f1b791845b Merge pull request #651 from pateljannat/editorjs-para
chore: bumped down paragraph plugin of editor js
2023-10-18 11:58:26 +05:30
Jannat Patel
6310845cdd chore: bunped down paragraph plugin of editor js 2023-10-18 11:47:15 +05:30
Hussain Nagaria
230cca63f3 feat: store batch/course in LMS payment 2023-10-17 23:18:43 +05:30
Hussain Nagaria
af9f4d4b1e fix: remove unused import 2023-10-17 22:17:28 +05:30
Jannat Patel
0111ff9c99 feat: quiz marks and passing percentage 2023-10-17 20:06:04 +05:30
Jannat Patel
12bec14c92 feat: quiz validations and marks 2023-10-16 19:52:36 +05:30
Jannat Patel
174ea1ddd4 chore: resolved conflicts 2023-10-16 11:19:34 +05:30
Tunde Akinyanmi
038a7463e1 update get_neighbours to use LessonBookmark and
return the correct bookmark string
2023-10-13 18:39:11 +01:00
Tunde Akinyanmi
a702909216 add LessonBookmark which is a data structure that
represents a bookmark.

While the underlying data structure is a tuple, it makes it easy to
abstract most of the logic we need and therefore allow the code to be
more readable.
2023-10-13 18:32:15 +01:00
Tunde Akinyanmi
8effd5614f remove cast operation from str to float.
It cause loss of the bookmark data
2023-10-13 18:14:11 +01:00
Jannat Patel
1046d28092 feat: quiz refactor 2023-10-11 12:25:46 +05:30
Jannat Patel
7678b89995 feat: setup frappe ui 2023-08-30 22:45:56 +05:30
347 changed files with 19489 additions and 10076 deletions

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)^(

View File

@@ -1,6 +1,6 @@
<p align="center">
<a href="https://www.frappelms.com/">
<img src="https://frappelms.com/files/lms-logo-medium.png" alt="Frappe LMS" width="120px" height="25px">
<img src="https://frappe.io/files/lms.png" alt="Frappe LMS" width="50px" height="50px">
</a>
<p align="center">Easy to use, open source, learning management system.</p>
</p>

5
SECURITY.md Normal file
View File

@@ -0,0 +1,5 @@
# Security Policy
The Frappe team and community take security issues seriously. To report a security issue, please go through the information mentioned [here](https://frappe.io/security).
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly and will keep you updated throughout the process.

View File

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

View File

@@ -4,6 +4,8 @@
$ git clone https://github.com/frappe/lms.git
$ cd lms
$ cd docker
```
**Step 2:** Run docker-compose

1
frappe-ui Submodule

Submodule frappe-ui added at c5faaae38e

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>

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"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/editorjs": "^2.29.0",
"@editorjs/embed": "^2.7.0",
"@editorjs/header": "^2.8.1",
"@editorjs/image": "^2.9.0",
"@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3",
"chart.js": "^4.4.1",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.50",
"lucide-vue-next": "^0.309.0",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",
"socket.io-client": "^4.7.2",
"tailwindcss": "^3.3.3",
"vue": "^3.2.25",
"vue-chartjs": "^5.3.0",
"vue-router": "^4.0.12"
},
"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/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,55 @@
<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 overflow-y-auto">
<SidebarLink
v-for="link in links"
:link="link"
:isCollapsed="isSidebarCollapsed"
class="mx-2 my-0.5"
/>
</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>
</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 } from 'vue'
import { getSidebarLinks } from '../utils'
const links = getSidebarLinks()
const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false)
}
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,75 @@
<template>
<div
class="flex flex-col border border-gray-200 rounded-md p-4 h-full"
style="min-height: 150px"
>
<Badge
v-if="batch.seat_count && batch.seats_left > 0"
theme="green"
class="self-start mb-2"
>
{{ batch.seats_left }} {{ __('Seat Left') }}
</Badge>
<Badge
v-else-if="batch.seat_count && batch.seats_left <= 0"
theme="red"
class="self-start mb-2"
>
{{ __('Sold Out') }}
</Badge>
<div class="text-xl font-semibold mb-1">
{{ batch.title }}
</div>
<div class="short-introduction">
{{ batch.description }}
</div>
<div class="mt-auto">
<div v-if="batch.amount" class="font-semibold text-lg mb-4">
{{ batch.price }}
</div>
<div class="flex items-center mb-3">
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
</div>
<div class="flex items-center mb-3">
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ dayjs(batch.start_date).format('DD MMM YYYY') }} -
{{ dayjs(batch.end_date).format('DD MMM YYYY') }}
</span>
</div>
<div class="flex items-center">
<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>
</div>
</template>
<script setup>
import { Calendar, Clock, BookOpen } from 'lucide-vue-next'
import { inject } from 'vue'
import { Badge } from 'frappe-ui'
import { formatTime } from '../utils'
const dayjs = inject('$dayjs')
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;
}
</style>

View File

@@ -0,0 +1,141 @@
<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="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 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)">
<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',
},
{
label: 'Lessons',
key: 'lesson_count',
},
{
label: 'Enrollments',
key: 'enrollment_count',
},
]
}
const removeCourse = createResource({
url: 'frappe.client.delete',
makeParams(values) {
return {
doctype: 'Batch Course',
name: values.course,
}
},
})
const removeCourses = (selections) => {
console.log(selections)
selections.forEach(async (course) => {
removeCourse.submit({ course })
await setTimeout(1000)
})
courses.reload()
}
</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,115 @@
<template>
<div v-if="batch.data" class="shadow rounded-md p-5" style="width: 300px">
<Badge
v-if="batch.data.seat_count && seats_left > 0"
theme="green"
class="self-start mb-2 float-right"
>
{{ seats_left }} {{ __('Seat Left') }}
</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>
<div class="flex items-center mb-3">
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
</span>
</div>
<div class="flex items-center">
<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>
<router-link
v-if="user?.data?.is_moderator"
:to="{
name: 'Batch',
params: {
batchName: batch.data.name,
},
}"
>
<Button variant="solid" class="w-full mt-4">
<span>
{{ __('Manage Batch') }}
</span>
</Button>
</router-link>
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-else-if="batch.data.paid_batch"
>
<Button 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"
>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="user?.data?.is_moderator"
: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 { formatNumberIntoCurrency, formatTime } from '@/utils'
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
import { inject, computed } from 'vue'
import { Badge, Button } from 'frappe-ui'
const dayjs = inject('$dayjs')
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
})
</script>

View File

@@ -0,0 +1,151 @@
<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)">
<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',
},
{
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) => {
selections.forEach(async (student) => {
removeStudent.submit({ student })
await setTimeout(1000)
})
students.reload()
}
</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,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,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,187 @@
<template>
<div
v-if="course.title"
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
style="min-height: 320px"
>
<div
class="course-image"
:class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"
>
<div class="flex relative top-4 left-4 w-fit flex-wrap">
<Badge theme="gray" size="md" class="mr-2" 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>
<div
v-if="user && course.membership"
class="w-full bg-gray-200 rounded-full h-1 mb-2"
>
<div
class="bg-gray-900 h-1 rounded-full"
:style="{ width: Math.ceil(course.membership.progress) + '%' }"
></div>
</div>
<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="mr-1"
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
>
<UserAvatar
v-for="instructor in course.instructors"
:user="instructor"
/>
</div>
<span v-if="course.instructors.length == 1">
{{ course.instructors[0].full_name }}
</span>
<span v-if="course.instructors.length == 2">
{{ course.instructors[0].first_name }} and
{{ course.instructors[1].first_name }}
</span>
<span v-if="course.instructors.length > 2">
{{ course.instructors[0].first_name }} and
{{ course.instructors.length - 1 }} others
</span>
</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'
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.orange.100');
color: theme('colors.orange.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,164 @@
<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>
<Button
v-else
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<span>
{{ __('Start Learning') }}
</span>
</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}`
}, 3000)
} else {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
})
console.log(props.course)
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 = () => {}
</script>

View File

@@ -0,0 +1,170 @@
<template>
<div class="text-base">
<div
v-if="title && (outline.data?.length || allowEdit)"
class="flex items-center justify-between mb-4"
>
<div class="font-semibold" :class="allowEdit ? 'text-base' : '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 px-2 py-3">
<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 class="pb-2">
<div v-for="lesson in chapter.lessons" :key="lesson.name">
<div class="outline-lesson pl-8 py-2">
<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">
<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 }}
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
/>
</div>
</router-link>
</div>
</div>
<div v-if="allowEdit" class="flex mt-2 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 { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
ChevronRight,
MonitorPlay,
HelpCircle,
FileText,
Check,
} from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
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,
},
})
const outline = createResource({
url: 'lms.lms.utils.get_course_outline',
cache: ['course_outline', props.courseName],
params: {
course: props.courseName,
},
auto: true,
})
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
}
</script>
<style>
.outline-lesson:has(.router-link-active) {
background-color: theme('colors.gray.100');
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<div v-if="reviews.data" 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">
<Star class="h-6 w-6 stroke-1 text-gray-50 fill-orange-500 mr-1" />
{{ avg_rating }} {{ __('ratings and ') }} {{ reviews.data.length }}
{{ __('reviews') }}
</div>
<div class="grid gap-8 mt-10">
<div v-for="(review, index) in reviews.data">
<div class="flex items-center">
<UserAvatar :user="review.owner_details" :size="'2xl'" />
<div class="mx-4">
<span class="text-lg font-medium mr-4">
{{ review.owner_details.full_name }}
</span>
<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,20 @@
<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"
>
<slot name="sidebar" />
<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,231 @@
<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"
@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 } from 'vue'
import { createToast } from '../utils'
const showTopics = defineModel('showTopics')
const newReply = ref('')
const socket = inject('$socket')
const user = inject('$user')
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 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,132 @@
<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 justify-center border mt-5 p-5 rounded-md">
<MessageSquareIcon class="w-10 h-10 stroke-1.5 text-gray-800 mr-2" />
<div>
<div class="text-xl font-semibold mb-2">
{{ __(emptyStateTitle) }}
</div>
<div>
{{ __(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, TextEditor } 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 { MessageSquareIcon } from 'lucide-vue-next'
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: 'No topics yet',
},
emptyStateText: {
type: String,
default: 'Be the first to start a discussion',
},
singleThread: {
type: Boolean,
default: false,
},
})
onMounted(() => {
if (user.data) topics.reload()
socket.on('new_discussion_topic', (data) => {
topics.refresh()
})
})
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,81 @@
<template>
<div class="flex shadow rounded-md p-4 h-full">
<img
:src="job.company_logo"
class="w-12 h-12 rounded-lg object-contain mr-4"
:alt="job.company_name"
/>
<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 class="flex items-center my-4">
<Badge :label="job.type" theme="green" size="lg" class="mr-4" />
<Badge :label="job.location.split(' ')[0]" theme="gray" size="lg">
<template #prefix>
<MapPin class="h-4 w-4 stroke-1.5" />
</template>
</Badge>
</div>
<div>
{{ __('posted on') }}
<span class="font-medium">
{{ dayjs(job.creation).format('DD MMM YYYY') }}
</span>
</div>
</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,99 @@
<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">
<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,142 @@
<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 an 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>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
import { Plus, FileText } from 'lucide-vue-next'
import { ref, watch } from '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'].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,110 @@
<template>
<Button
v-if="user.data.is_moderator"
variant="solid"
class="float-right mb-3"
@click="openLiveClassModal"
>
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add Live Class') }}
</span>
</Button>
<div class="text-lg font-semibold mb-4">
{{ __('Live Class') }}
</div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div v-for="cls in liveClasses.data">
<div class="border rounded-md p-3">
<div class="font-semibold text-lg mb-4">
{{ cls.title }}
</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="mb-5">
{{ cls.description }}
</div>
<div class="flex items-center gap-2">
<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>
<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,61 @@
<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="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, inject } from 'vue'
import { sessionStore } from '@/stores/session'
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const router = useRouter()
const tabs = computed(() => {
return getSidebarLinks()
})
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 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,68 @@
<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" />
</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 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,
},
}
},
})
const addCourse = (close) => {
createBatchCourse.submit(
{},
{
onSuccess() {
courses.value.reload()
close()
course.value = null
},
onError(err) {
showToast('Error', err.message[0] || err, 'x')
},
}
)
}
</script>

View File

@@ -0,0 +1,161 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add Chapter'),
size: 'lg',
actions: [
{
label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
variant: 'solid',
onClick: (close) =>
chapterDetail ? editChapter(close) : addChapter(close),
},
],
}"
>
<template #body-content>
<FormControl label="Title" v-model="chapter.title" class="mb-4" />
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import { defineModel, reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/'
const show = defineModel()
const outline = defineModel('outline')
const props = defineProps({
course: {
type: String,
required: true,
},
chapterDetail: {
type: Object,
},
})
const chapter = reactive({
title: '',
})
const chapterResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Course Chapter',
title: chapter.title,
description: chapter.description,
course: props.course,
},
}
},
})
const chapterEditResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Course Chapter',
name: props.chapterDetail?.name,
fieldname: 'title',
value: chapter.title,
}
},
})
const chapterReference = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Chapter Reference',
chapter: values.name,
parent: props.course,
parenttype: 'LMS Course',
parentfield: 'chapters',
},
}
},
})
const addChapter = (close) => {
chapterResource.submit(
{},
{
validate() {
if (!chapter.title) {
return 'Title is required'
}
},
onSuccess: (data) => {
chapterReference.submit(
{ name: data.name },
{
onSuccess(data) {
outline.value.reload()
createToast({
text: 'Chapter added successfully',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
},
onError(err) {
showError(err)
},
}
)
close()
},
onError(err) {
showError(err)
},
}
)
}
const editChapter = (close) => {
chapterEditResource.submit(
{},
{
validate() {
if (!chapter.title) {
return 'Title is required'
}
},
onSuccess() {
outline.value.reload()
createToast({
text: 'Chapter updated successfully',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
close()
},
onError(err) {
showError(err)
},
}
)
}
const showError = (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,
})
}
watch(
() => props.chapterDetail,
(newChapter) => {
chapter.title = newChapter?.title
}
)
</script>

View File

@@ -0,0 +1,114 @@
<template>
<Dialog
:options="{
title: props.title,
size: '2xl',
actions: [
{
label: 'Submit',
variant: 'solid',
onClick: (close) => submitTopic(close),
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Title') }}
</div>
<Input type="text" v-model="topic.title" />
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Details') }}
</div>
<TextEditor
:content="topic.reply"
@change="(val) => (topic.reply = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
import { reactive, defineModel } from 'vue'
const topics = defineModel('reloadTopics')
const props = defineProps({
title: {
type: String,
required: true,
},
doctype: {
type: String,
required: true,
},
docname: {
type: String,
required: true,
},
})
const topic = reactive({
title: '',
reply: '',
})
const topicResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Topic',
reference_doctype: props.doctype,
reference_docname: props.docname,
title: topic.title,
},
}
},
})
const replyResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Reply',
topic: values.topic,
reply: topic.reply,
},
}
},
})
const submitTopic = (close) => {
topicResource.submit(
{},
{
onSuccess(data) {
replyResource.submit(
{
topic: data.name,
},
{
onSuccess() {
topic.title = ''
topic.reply = ''
topics.value.reload()
close()
},
}
)
},
}
)
}
</script>

View File

@@ -0,0 +1,188 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Schedule Evaluation'),
size: 'xl',
actions: [
{
label: __('Submit'),
variant: 'solid',
onClick: (close) => submitEvaluation(close),
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Course') }}
</div>
<Select v-model="evaluation.course" :options="getCourses()" />
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Date') }}
</div>
<DatePicker v-model="evaluation.date" />
</div>
<div v-if="slots.data?.length">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Select a slot') }}
</div>
<div class="grid grid-cols-2 gap-2">
<div v-for="slot in slots.data">
<div
class="text-base text-center border rounded-md bg-gray-200 p-2 cursor-pointer"
@click="saveSlot(slot)"
:class="{
'border-gray-900': evaluation.start_time == slot.start_time,
}"
>
{{ formatTime(slot.start_time) }} -
{{ formatTime(slot.end_time) }}
</div>
</div>
</div>
</div>
<div v-else class="text-sm italic text-red-600">
{{ __('No slots available for this date.') }}
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createResource, Select, DatePicker } from 'frappe-ui'
import { defineModel, reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/'
const user = inject('$user')
const dayjs = inject('$dayjs')
const show = defineModel()
const evaluations = defineModel('reloadEvals')
const props = defineProps({
courses: {
type: Array,
default: [],
},
batch: {
type: String,
default: null,
},
endDate: {
type: String,
default: null,
},
})
let evaluation = reactive({
course: '',
date: '',
start_time: '',
end_time: '',
day: '',
batch: props.batch,
member: user.data.name,
})
const createEvaluation = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Certificate Request',
batch_name: values.batch,
...values,
},
}
},
})
function submitEvaluation(close) {
createEvaluation.submit(evaluation, {
validate() {
if (!evaluation.course) {
return 'Please select a course.'
}
if (!evaluation.date) {
return 'Please select a date.'
}
if (!evaluation.start_time) {
return 'Please select a slot.'
}
if (dayjs(evaluation.date).isSameOrBefore(dayjs(), 'day')) {
return 'Please select a future date.'
}
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
return `Please select a date before the end date ${dayjs(
props.endDate
).format('DD MMMM YYYY')}.`
}
},
onSuccess() {
evaluations.value.reload()
close()
},
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 getCourses = () => {
let courses = []
for (const course of props.courses) {
courses.push({
label: course.title,
value: course.course,
})
}
return courses
}
const slots = createResource({
url: 'lms.lms.doctype.course_evaluator.course_evaluator.get_schedule',
makeParams(values) {
return {
course: values.course,
date: values.date,
batch: props.batch,
}
},
})
watch(
() => evaluation.date,
(date) => {
evaluation.start_time = ''
if (date) {
slots.submit(evaluation)
}
}
)
watch(
() => evaluation.course,
(course) => {
evaluation.date = ''
evaluation.start_time = ''
slots.reset()
}
)
const saveSlot = (slot) => {
evaluation.start_time = slot.start_time
evaluation.end_time = slot.end_time
evaluation.day = slot.day
}
</script>

View File

@@ -0,0 +1,137 @@
<template>
<Dialog
v-model="show"
class="text-base"
:options="{
title: __('Apply for this job'),
size: 'lg',
actions: [
{
label: 'Submit',
variant: 'solid',
onClick: (close) => {
submitResume(close)
},
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<p>
{{
__(
'Submit your resume to proceed with your application for this position. Upon submission, it will be shared with the job poster.'
)
}}
</p>
<div v-if="!resume">
<FileUploader
:fileTypes="['.pdf']"
:validateFile="validateFile"
@success="
(file) => {
resume = file
}
"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload your resume'
}}
</Button>
</div>
</template>
</FileUploader>
</div>
<div v-else class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span>
{{ resume.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(resume.file_size) }}
</span>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FileUploader, Button, createResource } from 'frappe-ui'
import { FileText } from 'lucide-vue-next'
import { ref, inject, defineModel } from 'vue'
import { createToast, getFileSize } from '@/utils/'
const resume = ref(null)
const show = defineModel()
const user = inject('$user')
const application = defineModel('application')
const props = defineProps({
job: {
type: String,
required: true,
},
})
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (extension != 'pdf') {
return 'Only PDF file is allowed'
}
}
const jobApplication = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Job Application',
user: user.data?.name,
resume: resume.value?.file_name,
job: props.job,
},
}
},
})
const submitResume = (close) => {
jobApplication.submit(
{},
{
validate() {
if (!resume.value) {
return 'Please upload your resume'
}
},
onSuccess() {
createToast({
title: 'Success',
text: 'Your application has been submitted',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
application.value.reload()
close()
},
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,226 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Create a Live Class'),
size: 'xl',
actions: [
{
label: 'Submit',
variant: 'solid',
onClick: (close) => submitLiveClass(close),
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Title') }}
</div>
<Input type="text" v-model="liveClass.title" />
</div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
<Tooltip
class="flex items-center"
:text="
__(
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
)
"
>
<span>
{{ __('Time') }}
</span>
<Info class="stroke-2 w-3 h-3 ml-1" />
</Tooltip>
</div>
<Input v-model="liveClass.time" />
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Timezone') }}
</div>
<Select
v-model="liveClass.timezone"
:options="getTimezoneOptions()"
/>
</div>
</div>
<div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Date') }}
</div>
<DatePicker v-model="liveClass.date" inputClass="w-full" />
</div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
<Tooltip
class="flex items-center"
:text="__('Duration of the live class in minutes')"
>
<span>
{{ __('Duration') }}
</span>
<Info class="stroke-2 w-3 h-3 ml-1" />
</Tooltip>
</div>
<Input type="number" v-model="liveClass.duration" />
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Auto Recording') }}
</div>
<Select
v-model="liveClass.auto_recording"
:options="getRecordingOptions()"
/>
</div>
</div>
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Description') }}
</div>
<Textarea v-model="liveClass.description" />
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import {
Input,
DatePicker,
Select,
Textarea,
Dialog,
createResource,
Tooltip,
} from 'frappe-ui'
import { reactive, inject } from 'vue'
import { getTimezones, createToast } from '@/utils/'
import { Info } from 'lucide-vue-next'
const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel()
const user = inject('$user')
const dayjs = inject('$dayjs')
const props = defineProps({
batch: {
type: String,
default: null,
},
})
let liveClass = reactive({
title: '',
description: '',
date: '',
time: '',
duration: '',
timezone: '',
auto_recording: 'No Recording',
batch: props.batch,
host: user.data.name,
})
const getTimezoneOptions = () => {
return getTimezones().map((timezone) => {
return {
label: timezone,
value: timezone,
}
})
}
const getRecordingOptions = () => {
return [
{
label: 'No Recording',
value: 'No Recording',
},
{
label: 'Local',
value: 'Local',
},
{
label: 'Cloud',
value: 'Cloud',
},
]
}
const createLiveClass = createResource({
url: 'lms.lms.doctype.lms_batch.lms_batch.create_live_class',
makeParams(values) {
return {
doctype: 'LMS Live Class',
batch_name: values.batch,
...values,
}
},
})
const submitLiveClass = (close) => {
createLiveClass.submit(liveClass, {
validate() {
if (!liveClass.title) {
return 'Please enter a title.'
}
if (!liveClass.date) {
return 'Please select a date.'
}
if (dayjs(liveClass.date).isSameOrBefore(dayjs(), 'day')) {
return 'Please select a future date.'
}
if (!liveClass.time) {
return 'Please select a time.'
}
if (!valideTime()) {
return 'Please enter a valid time in the format HH:mm.'
}
if (!liveClass.duration) {
return 'Please select a duration.'
}
if (!liveClass.timezone) {
return 'Please select a timezone.'
}
},
onSuccess() {
liveClasses.value.reload()
close()
},
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 valideTime = () => {
let time = liveClass.time.split(':')
if (time.length != 2) {
return false
}
if (time[0] < 0 || time[0] > 23) {
return false
}
if (time[1] < 0 || time[1] > 59) {
return false
}
return true
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Write a Review'),
size: 'xl',
actions: [
{
label: 'Submit',
variant: 'solid',
onClick: (close) => submitReview(close),
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Rating') }}
</div>
<Rating v-model="review.rating" />
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Review') }}
</div>
<Textarea type="text" size="md" rows="5" v-model="review.review" />
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Textarea, createResource } from 'frappe-ui'
import { defineModel, reactive } from 'vue'
import Rating from '@/components/Controls/Rating.vue'
import { createToast } from '@/utils/'
const show = defineModel()
const reviews = defineModel('reloadReviews')
const hasReviewed = defineModel('hasReviewed')
let review = reactive({
review: '',
rating: 0,
})
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
const createReview = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Course Review',
course: props.courseName,
...values,
},
}
},
})
function submitReview(close) {
review.rating = review.rating / 5
createReview.submit(review, {
validate() {
if (!review.rating) {
return 'Please enter a rating.'
}
},
onSuccess() {
reviews.value.reload()
hasReviewed.value.reload()
},
onError(err) {
createToast({
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'text-red-600 bg-red-300',
})
},
})
close()
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add a Student'),
size: 'sm',
actions: [
{
label: 'Submit',
variant: 'solid',
onClick: (close) => addStudent(close),
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<Link
doctype="User"
v-model="student"
:filters="{ ignore_user_type: 1 }"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import { ref } from 'vue'
import Link from '@/components/Controls/Link.vue'
const students = defineModel('reloadStudents')
const student = ref()
const show = defineModel()
const props = defineProps({
batch: {
type: String,
default: null,
},
})
const studentResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Batch Student',
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'students',
student: student.value,
},
}
},
})
const addStudent = (close) => {
studentResource.submit(
{},
{
onSuccess() {
students.value.reload()
close()
student.value = null
},
}
)
}
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
<div class="border-b px-5 py-3 font-medium">
<span
class="inline-flex items-center before:bg-red-600 before:w-2 before:h-2 before:rounded-md before:mr-2"
></span>
{{ __(title) }}
</div>
<div class="px-5 py-3">
<div class="mb-4 leading-6">
{{ __(text) }}
</div>
<Button variant="solid" class="w-full" @click="redirect()">
{{ __(buttonLabel) }}
</Button>
</div>
</div>
</template>
<script setup>
import { Button } from 'frappe-ui'
const props = defineProps({
title: {
type: String,
default: 'Not Permitted',
},
text: {
type: String,
default: 'You are not permitted to access this page.',
},
buttonLabel: {
type: String,
default: 'Login',
},
buttonLink: {
type: String,
default: '/login',
},
})
const redirect = () => {
window.location.href = props.buttonLink
}
</script>

View File

@@ -0,0 +1,454 @@
<template>
<div v-if="quiz.data">
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
<div class="leading-relaxed">
{{
__('This quiz consists of {0} questions.').format(
quiz.data.questions.length
)
}}
</div>
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
{{
__(
'You will have to get {0}% correct answers in order to pass the quiz.'
).format(quiz.data.passing_percentage)
}}
</div>
<div v-if="quiz.data.max_attempts" class="leading-relaxed">
{{
__('You can attempt this quiz {0}.').format(
quiz.data.max_attempts == 1
? '1 time'
: `${quiz.data.max_attempts} times`
)
}}
</div>
<div v-if="quiz.data.time" class="leading-relaxed">
{{
__(
'The quiz has a time limit. For each question you will be given {0} seconds.'
).format(quiz.data.time)
}}
</div>
</div>
<div v-if="activeQuestion == 0">
<div class="border text-center p-20 rounded-md">
<div class="font-semibold text-lg">
{{ quiz.data.title }}
</div>
<Button
v-if="
!quiz.data.max_attempts ||
attempts.data?.length < quiz.data.max_attempts
"
@click="startQuiz"
class="mt-2"
>
<span>
{{ __('Start') }}
</span>
</Button>
<div v-else>
{{
__(
'You have already exceeded the maximum number of attempts allowed for this quiz.'
)
}}
</div>
</div>
</div>
<div v-else-if="!quizSubmission.data">
<div v-for="(question, qtidx) in quiz.data.questions">
<div
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
class="border rounded-md p-5"
>
<div class="flex justify-between">
<div class="text-sm">
<span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}:
</span>
<span>
{{
questionDetails.data.multiple
? __('Choose all answers that apply')
: __('Choose one answer')
}}
</span>
</div>
<div class="text-gray-900 text-sm font-semibold item-left">
{{ question.marks }}
{{ question.marks == 1 ? __('Mark') : __('Marks') }}
</div>
</div>
<div class="text-gray-900 font-semibold mt-2">
{{ questionDetails.data.question }}
</div>
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
<label
v-if="questionDetails.data[`option_${index}`]"
class="flex items-center bg-gray-200 rounded-md p-3 mt-4 w-full cursor-pointer focus:border-blue-600"
>
<input
v-if="!showAnswers.length && !questionDetails.data.multiple"
type="radio"
:name="encodeURIComponent(questionDetails.data.question)"
class="w-3.5 h-3.5 text-gray-900 focus:ring-gray-200"
@change="markAnswer(index)"
/>
<input
v-else-if="!showAnswers.length && questionDetails.data.multiple"
type="checkbox"
:name="encodeURIComponent(questionDetails.data.question)"
class="w-3.5 h-3.5 text-gray-900 rounded-sm focus:ring-gray-200"
@change="markAnswer(index)"
/>
<div
v-else-if="quiz.data.show_answers"
v-for="(answer, idx) in showAnswers"
>
<div v-if="index - 1 == idx">
<CheckCircle v-if="answer" class="w-4 h-4 text-green-500" />
<MinusCircle
v-else-if="questionDetails.data[`is_correct_${index}`]"
class="w-4 h-4 text-green-500"
/>
<XCircle
v-else-if="answer == 0"
class="w-4 h-4 text-red-500"
/>
<MinusCircle v-else class="w-4 h-4" />
</div>
</div>
<span class="ml-2">
{{ questionDetails.data[`option_${index}`] }}
</span>
</label>
<div
v-if="questionDetails.data[`explanation_${index}`]"
class="mt-2 text-sm hidden"
>
{{ questionDetails.data[`explanation_${index}`] }}
</div>
</div>
<div class="flex items-center justify-between mt-8">
<div>
{{
__('Question {0} of {1}').format(
activeQuestion,
quiz.data.questions.length
)
}}
</div>
<Button
v-if="quiz.data.show_answers && !showAnswers.length"
@click="checkAnswer()"
>
<span>
{{ __('Check') }}
</span>
</Button>
<Button
v-else-if="activeQuestion != quiz.data.questions.length"
@click="nextQuetion()"
>
<span>
{{ __('Next') }}
</span>
</Button>
<Button v-else @click="submitQuiz()">
<span>
{{ __('Submit') }}
</span>
</Button>
</div>
</div>
</div>
</div>
<div v-else class="border rounded-md p-20 text-center">
<div class="text-lg font-semibold">
{{ __('Quiz Summary') }}
</div>
<div>
{{
__(
'You got {0}% correct answers with a score of {1} out of {2}'
).format(
Math.ceil(quizSubmission.data.percentage),
quizSubmission.data.score,
quizSubmission.data.score_out_of
)
}}
</div>
<Button
@click="resetQuiz()"
class="mt-2"
v-if="
!quiz.data.max_attempts ||
attempts?.data.length < quiz.data.max_attempts
"
>
<span>
{{ __('Try Again') }}
</span>
</Button>
</div>
<div
v-if="quiz.data.show_submission_history && attempts?.data"
class="mt-10"
>
<ListView
:columns="getSubmissionColumns()"
:rows="attempts?.data"
row-key="name"
:options="{ selectable: false, showTooltip: false }"
>
</ListView>
</div>
</div>
</template>
<script setup>
import {
createDocumentResource,
Button,
createResource,
ListView,
} from 'frappe-ui'
import { ref, watch, reactive, inject } from 'vue'
import { createToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils'
const user = inject('$user')
const activeQuestion = ref(0)
const currentQuestion = ref('')
const selectedOptions = reactive([0, 0, 0, 0])
const showAnswers = reactive([])
const props = defineProps({
quizName: {
type: String,
required: true,
},
})
const quiz = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Quiz',
name: props.quizName,
}
},
cache: ['quiz', props.quizName],
auto: true,
onSuccess(data) {
attempts.reload()
resetQuiz()
},
})
const attempts = createResource({
url: 'frappe.client.get_list',
makeParams(values) {
return {
doctype: 'LMS Quiz Submission',
filters: {
member: user.data?.name,
quiz: quiz.data?.name,
},
fields: [
'name',
'creation',
'score',
'score_out_of',
'percentage',
'passing_percentage',
],
order_by: 'creation desc',
}
},
transform(data) {
data.forEach((submission, index) => {
submission.creation = timeAgo(submission.creation)
submission.idx = index + 1
})
},
})
const quizSubmission = createResource({
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
makeParams(values) {
return {
quiz: quiz.data.name,
results: localStorage.getItem(quiz.data.title),
}
},
})
const questionDetails = createResource({
url: 'lms.lms.utils.get_question_details',
makeParams(values) {
return {
question: currentQuestion.value,
}
},
})
watch(activeQuestion, (value) => {
if (value > 0) {
currentQuestion.value = quiz.data.questions[value - 1].question
questionDetails.reload()
}
})
watch(
() => props.quizName,
(newName) => {
console.log(newName)
if (newName) {
quiz.reload()
}
}
)
const startQuiz = () => {
activeQuestion.value = 1
localStorage.removeItem(quiz.data.title)
}
const markAnswer = (index) => {
if (!questionDetails.data.multiple)
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
selectedOptions[index - 1] = selectedOptions[index - 1] ? 0 : 1
}
const getAnswers = () => {
let answers = []
selectedOptions.forEach((value, index) => {
if (selectedOptions[index])
answers.push(questionDetails.data[`option_${index + 1}`])
})
return answers
}
const checkAnswer = () => {
let answers = getAnswers()
if (!answers.length) {
createToast({
title: 'Please select an option',
icon: 'alert-circle',
iconClasses: 'text-yellow-600 bg-yellow-100',
})
return
}
createResource({
url: 'lms.lms.doctype.lms_quiz.lms_quiz.check_answer',
params: {
question: currentQuestion.value,
type: questionDetails.data.type,
answers: JSON.stringify(answers),
},
auto: true,
onSuccess(data) {
selectedOptions.forEach((option, index) => {
if (option) {
showAnswers[index] = option && data[index]
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
showAnswers[index] = 0
} else {
showAnswers[index] = undefined
}
})
addToLocalStorage()
if (!quiz.data.show_answers) {
resetQuestion()
}
},
})
}
const addToLocalStorage = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
let questionData = {
question_index: activeQuestion.value,
answers: getAnswers().join(),
is_correct: showAnswers.filter((answer) => {
return answer != undefined
}),
}
quizData ? quizData.push(questionData) : (quizData = [questionData])
localStorage.setItem(quiz.data.title, JSON.stringify(quizData))
}
const nextQuetion = () => {
if (!quiz.data.show_answers) {
checkAnswer()
} else {
resetQuestion()
}
}
const resetQuestion = () => {
if (activeQuestion.value == quiz.data.questions.length) return
activeQuestion.value = activeQuestion.value + 1
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
showAnswers.length = 0
}
const submitQuiz = () => {
if (!quiz.data.show_answers) {
checkAnswer()
setTimeout(() => {
createSubmission()
}, 500)
return
}
createSubmission()
}
const createSubmission = () => {
quizSubmission.reload().then(() => {
attempts.reload()
})
}
const resetQuiz = () => {
activeQuestion.value = 0
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
showAnswers.length = 0
quizSubmission.reset()
}
const getSubmissionColumns = () => {
return [
{
label: 'No.',
key: 'idx',
},
{
label: 'Date',
key: 'creation',
},
{
label: 'Score',
key: 'score',
align: 'center',
},
{
label: 'Score out of',
key: 'score_out_of',
align: 'center',
},
{
label: 'Percentage',
key: 'percentage',
align: 'center',
},
]
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<Quiz v-if="user.data" :quizName="quiz"></Quiz>
<div v-else class="border rounded-md text-center py-20">
<div>
{{ __('Please login to access the quiz.') }}
</div>
<Button @click="redirectToLogin()" class="mt-2">
<span>
{{ __('Login') }}
</span>
</Button>
</div>
</template>
<script setup>
import { inject } from 'vue'
import { Button } from 'frappe-ui'
import Quiz from '@/components/Quiz.vue'
const user = inject('$user')
const props = defineProps({
quiz: {
type: String,
required: true,
},
})
</script>

View File

@@ -0,0 +1,61 @@
<template>
<button
v-if="link && !link.onlyMobile"
class="flex h-7 cursor-pointer items-center rounded text-gray-800 duration-300 ease-in-out focus:outline-none focus:transition-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-gray-400"
:class="isActive ? 'bg-white shadow-sm' : 'hover:bg-gray-100'"
@click="handleClick"
>
<div
class="flex items-center duration-300 ease-in-out"
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
>
<Tooltip :text="link.label" placement="right">
<slot name="icon">
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<component
:is="link.icon"
class="h-5 w-5 stroke-1.5 text-gray-800"
/>
</span>
</slot>
</Tooltip>
<span
class="flex-shrink-0 text-base duration-300 ease-in-out"
:class="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'
: 'ml-2 w-auto opacity-100'
"
>
{{ link.label }}
</span>
</div>
</button>
</template>
<script setup>
import { Tooltip } from 'frappe-ui'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps({
link: {
type: Object,
required: true,
},
isCollapsed: {
type: Boolean,
default: false,
},
})
function handleClick() {
router.push({ name: props.link.to })
}
let isActive = computed(() => {
return props.link?.activeFor?.includes(router.currentRoute.value.name)
})
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div>
<div class="mb-1.5 text-sm text-gray-700">
{{ __(label) }}
</div>
<div class="flex items-center">
{{ tags }}
<div
v-for="tag in tags?.split(', ')"
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
@click="removeTag(tag)"
/>
</div>
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
</div>
</div>
</template>
<script setup>
import { FormControl } from 'frappe-ui'
import { X } from 'lucide-vue-next'
import { ref } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
label: {
type: String,
default: 'Tags',
},
})
console.log(props.modelValue)
let tags = ref(props.modelValue)
console.log(tags.value)
const emit = defineEmits(['update:modelValue'])
let newTag = ref('')
let emitChange = (value) => {
emit('update:modelValue', value)
}
const updateTags = () => {
if (newTag) {
tags.value = tags.value ? `${tags.value}, ${newTag}` : newTag
newTag.value = ''
emitChange(tags.value)
}
}
const removeTag = (tag) => {
tags.value = tags.value.replace(tag, '').replace(', ,', ',')
emitChange(tags.value)
}
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div class="mb-10">
<Button v-if="isStudent" @click="openEvalModal" class="float-right">
{{ __('Schedule Evaluation') }}
</Button>
<div class="text-lg font-semibold mb-4">
{{ __('Upcoming Evaluations') }}
</div>
<div v-if="upcoming_evals.data?.length">
<div class="grid grid-cols-2 gap-4">
<div v-for="evl in upcoming_evals.data">
<div class="border rounded-md p-3">
<div class="font-semibold mb-3">
{{ evl.course_title }}
</div>
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(evl.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(evl.start_time) }}
</span>
</div>
<div class="flex items-center">
<UserCog2 class="w-4 h-4 stroke-1.5" />
<span class="ml-2 font-medium">
{{ evl.evaluator_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-sm italic text-gray-600">
{{ __('No upcoming evaluations.') }}
</div>
</div>
<EvaluationModal
:batch="batch"
:endDate="endDate"
:courses="courses"
v-model="showEvalModal"
v-model:reloadEvals="upcoming_evals"
/>
</template>
<script setup>
import { Calendar, Clock, UserCog2 } from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '../utils'
import { Button, createResource } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
const dayjs = inject('$dayjs')
const user = inject('$user')
const showEvalModal = ref(false)
const props = defineProps({
batch: {
type: String,
default: null,
},
courses: {
type: Array,
default: [],
},
isStudent: {
type: Boolean,
default: false,
},
endDate: {
type: String,
default: null,
},
})
const upcoming_evals = createResource({
url: 'lms.lms.utils.get_upcoming_evals',
cache: ['upcoming_evals', user.data.name],
params: {
student: user.data.name,
courses: props.courses.map((course) => course.course),
},
auto: true,
})
function openEvalModal() {
showEvalModal.value = true
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<Avatar
class="avatar border border-gray-300"
v-if="user"
:label="user.full_name"
:image="user.user_image"
:size="size"
v-bind="$attrs"
/>
</template>
<script setup>
import { Avatar } from 'frappe-ui'
const props = defineProps({
user: {
type: Object,
default: null,
},
size: {
type: String,
},
})
</script>

View File

@@ -0,0 +1,115 @@
<template>
<Dropdown :options="userDropdownOptions">
<template v-slot="{ open }">
<button
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
:class="
isCollapsed
? 'px-0 w-auto'
: open
? 'bg-white shadow-sm px-2 w-52'
: 'hover:bg-gray-200 px-2 w-52'
"
>
<span
v-if="branding.data?.brand_html"
v-html="branding.data?.brand_html"
class="w-8 h-8 rounded flex-shrink-0"
></span>
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
<div
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
:class="
isCollapsed
? 'opacity-0 ml-0 w-0 overflow-hidden'
: 'opacity-100 ml-2 w-auto'
"
>
<div class="text-base font-medium text-gray-900 leading-none">
<span v-if="branding.data?.brand_name">
{{ branding.data?.brand_name }}
</span>
<span v-else> Learning </span>
</div>
<div v-if="user" class="mt-1 text-sm text-gray-700 leading-none">
{{ convertToTitleCase(user.split('@')[0]) }}
</div>
</div>
<div
class="duration-300 ease-in-out"
:class="
isCollapsed
? 'opacity-0 ml-0 w-0 overflow-hidden'
: 'opacity-100 ml-2 w-auto'
"
>
<ChevronDown class="h-4 w-4 text-gray-700" />
</div>
</button>
</template>
</Dropdown>
</template>
<script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session'
import { Dropdown, createResource } from 'frappe-ui'
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '../utils'
import { onMounted } from 'vue'
const router = useRouter()
const props = defineProps({
isCollapsed: {
type: Boolean,
default: false,
},
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
cache: true,
auto: true,
onSuccess(data) {
document.querySelector("link[rel='icon']").href = data.favicon
},
})
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const userDropdownOptions = [
/* {
icon: User,
label: 'My Profile',
onClick: () => {
router.push(`/user/${user.data?.username}`)
},
condition: () => {
return isLoggedIn
},
}, */
{
icon: LogOut,
label: 'Log out',
onClick: () => {
logout.submit().then(() => {
isLoggedIn = false
})
},
condition: () => {
return isLoggedIn
},
},
{
icon: LogIn,
label: 'Log in',
onClick: () => {
window.location.href = '/login'
},
condition: () => {
return !isLoggedIn
},
},
]
</script>

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