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 10084 deletions
+3 -1
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
+1 -1
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)^(
+1 -1
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
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.
+1 -1
View File
@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0,
},
e2e: {
baseUrl: "http://dd1:8000",
baseUrl: "http://pyp:8000",
},
});
+2
View File
@@ -4,6 +4,8 @@
$ git clone https://github.com/frappe/lms.git
$ cd lms
$ cd docker
```
**Step 2:** Run docker-compose
Submodule
+1
Submodule frappe-ui added at c5faaae38e
+5
View File
@@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
+4
View File
@@ -0,0 +1,4 @@
{
"semi": false,
"singleQuote": true
}
+42
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
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
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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

+25
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.
+152
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");
}
+62
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>
+55
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>
+98
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>
+75
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>
+141
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>
@@ -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>
+115
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>
+151
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>
@@ -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>
+146
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>
@@ -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>
+187
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>
@@ -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>
+170
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>
+103
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>
+30
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>
+20
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>
@@ -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>
+132
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>
@@ -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>
@@ -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>
@@ -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>
+36
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>
+81
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>
+99
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>
+142
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>
+110
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>
+61
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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
+44
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>
+454
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>
+26
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>
+61
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>
+59
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>
@@ -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>
+22
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>
+115
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