Compare commits
375 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c1a6b540 | ||
|
|
8dba0e8242 | ||
|
|
ee715f6387 | ||
|
|
b770b30334 | ||
|
|
d61abac126 | ||
|
|
ccf28b8012 | ||
|
|
3762cb06bb | ||
|
|
15400f2a3e | ||
|
|
20d1b1fe83 | ||
|
|
73844f8813 | ||
|
|
2187553625 | ||
|
|
984b2a5dea | ||
|
|
9098d9454f | ||
|
|
027dd93fb5 | ||
|
|
a005adc89a | ||
|
|
866ef04fbf | ||
|
|
00b6f97e3a | ||
|
|
a1d21b1a2a | ||
|
|
7358ea43d8 | ||
|
|
88c69311eb | ||
|
|
c1e45e5d0d | ||
|
|
fe78de2417 | ||
|
|
4c1fc201e6 | ||
|
|
3f5d270915 | ||
|
|
a452fbeb07 | ||
|
|
a6f02c245f | ||
|
|
cb4f9129d6 | ||
|
|
9c5d64c211 | ||
|
|
41dc0ecc60 | ||
|
|
6b9409b889 | ||
|
|
ea66eeed6c | ||
|
|
a419d28ef1 | ||
|
|
481dfc24fd | ||
|
|
ed686a7d52 | ||
|
|
b4c5a07800 | ||
|
|
6ae16f7fef | ||
|
|
4aae2ed3b8 | ||
|
|
81d4137b20 | ||
|
|
77ecb02a17 | ||
|
|
4a375f92ed | ||
|
|
7caf91460a | ||
|
|
0e015c8b97 | ||
|
|
7b69ddb14d | ||
|
|
2271eb270e | ||
|
|
7e5b2e4e79 | ||
|
|
124b9d9ea5 | ||
|
|
36076068ec | ||
|
|
c868354b5b | ||
|
|
db91f0b2a0 | ||
|
|
d7e83bb78e | ||
|
|
feb2a39e05 | ||
|
|
a6cf910d05 | ||
|
|
b891b44ac6 | ||
|
|
026a3ebb81 | ||
|
|
71ba246011 | ||
|
|
a391204fa6 | ||
|
|
9c773399a8 | ||
|
|
528b85352a | ||
|
|
249c369c14 | ||
|
|
9803fc1031 | ||
|
|
299fde1c98 | ||
|
|
7f55734fbb | ||
|
|
efe230865a | ||
|
|
6e52e684c8 | ||
|
|
99d880297a | ||
|
|
dec706ae72 | ||
|
|
2e60f0a0c2 | ||
|
|
ef612f86e5 | ||
|
|
9c16e03ea7 | ||
|
|
7780c0310e | ||
|
|
b0a23c0d1a | ||
|
|
05c85cea08 | ||
|
|
1ffae0a1de | ||
|
|
15cbccd15f | ||
|
|
266b2f2ac8 | ||
|
|
26f9fb4199 | ||
|
|
67887fb6ef | ||
|
|
3d102e39ff | ||
|
|
ddd9089130 | ||
|
|
d8ce88ab57 | ||
|
|
01794a47c6 | ||
|
|
17626dbbdb | ||
|
|
e5bd86658d | ||
|
|
e911dc1353 | ||
|
|
27e3e5aa6a | ||
|
|
5b65525bf1 | ||
|
|
277804f8b1 | ||
|
|
4c77802e3c | ||
|
|
aacfea6ea5 | ||
|
|
6d55040e43 | ||
|
|
290f785a47 | ||
|
|
39ef187f6b | ||
|
|
a7a475e763 | ||
|
|
6eb380ea38 | ||
|
|
4d150cb323 | ||
|
|
09d6d99b14 | ||
|
|
5e7fd8baff | ||
|
|
52c159e2e8 | ||
|
|
67e8feb879 | ||
|
|
a5b61d5244 | ||
|
|
decc3a16ed | ||
|
|
7f39e9f0cc | ||
|
|
95afa1a6ad | ||
|
|
0d0bb5f9e2 | ||
|
|
3dd5ce5035 | ||
|
|
549e56d551 | ||
|
|
50b6215d1e | ||
|
|
ff69bfdce7 | ||
|
|
c04cc8ec0f | ||
|
|
f324de2254 | ||
|
|
40af4e6f34 | ||
|
|
5d9b66b5cb | ||
|
|
d2a8277c13 | ||
|
|
ada85fc0f3 | ||
|
|
505345eff7 | ||
|
|
2911ade880 | ||
|
|
8980dc8f9c | ||
|
|
d94a1c47c0 | ||
|
|
99c3e5182d | ||
|
|
70e39fee40 | ||
|
|
26d6bec8a0 | ||
|
|
c9ac1a1402 | ||
|
|
6949c1092c | ||
|
|
aae8a54481 | ||
|
|
e1d93bf670 | ||
|
|
fea0533cb1 | ||
|
|
5cd991f02a | ||
|
|
50a8a605d5 | ||
|
|
9ce7d8f5d6 | ||
|
|
eae2587e4c | ||
|
|
323097f201 | ||
|
|
014499888a | ||
|
|
5662de21ae | ||
|
|
17c2eba455 | ||
|
|
1f2c986e8f | ||
|
|
12040b5f6d | ||
|
|
20a985848f | ||
|
|
c06c6169e5 | ||
|
|
917aeb79ef | ||
|
|
c4f36a39fe | ||
|
|
befedc30ad | ||
|
|
d3bc67daa2 | ||
|
|
5d7e211367 | ||
|
|
fa9daa01ec | ||
|
|
0ed9dc63b8 | ||
|
|
5dd6b33eb2 | ||
|
|
1210b823c7 | ||
|
|
04240b4b3d | ||
|
|
787f592a1a | ||
|
|
e7363fbd40 | ||
|
|
e2762825e5 | ||
|
|
bbbca70c71 | ||
|
|
8dde423866 | ||
|
|
fc4c1c2b7e | ||
|
|
bf02e2de3f | ||
|
|
a26ba4dc6e | ||
|
|
f187cc9314 | ||
|
|
c15c6374f9 | ||
|
|
acec382dfe | ||
|
|
fbc078c6b6 | ||
|
|
170b20185a | ||
|
|
3e8489c13b | ||
|
|
18dfc4c23e | ||
|
|
e6bae3dc77 | ||
|
|
6f9f27c030 | ||
|
|
874bef74c7 | ||
|
|
ad483e0916 | ||
|
|
5b4bbaec20 | ||
|
|
b8ae0db0bd | ||
|
|
f2c18fad52 | ||
|
|
9716655b94 | ||
|
|
efb317191c | ||
|
|
a47b5db40c | ||
|
|
ec94796b9c | ||
|
|
e3e0cd61a2 | ||
|
|
a438473279 | ||
|
|
12b5b8b509 | ||
|
|
22442b47a8 | ||
|
|
30c8b7d64f | ||
|
|
b643575c4f | ||
|
|
7dd7124fac | ||
|
|
4b1eebf5bb | ||
|
|
3257943926 | ||
|
|
24246c83e0 | ||
|
|
a26787f478 | ||
|
|
ec3b88f890 | ||
|
|
7f5f1dad92 | ||
|
|
b6db128214 | ||
|
|
8831635db2 | ||
|
|
e19198b720 | ||
|
|
f618d9dc1a | ||
|
|
66a667a0a3 | ||
|
|
8a4c67f712 | ||
|
|
fa6ef2e989 | ||
|
|
7450b99197 | ||
|
|
023fd272b1 | ||
|
|
84067cb027 | ||
|
|
3087ef70e7 | ||
|
|
387385bb1c | ||
|
|
6766d0d08c | ||
|
|
371d890793 | ||
|
|
57046c1b38 | ||
|
|
2a64144e94 | ||
|
|
9b0320ccf1 | ||
|
|
23f209131e | ||
|
|
d71f1c7f9a | ||
|
|
d21ea2c854 | ||
|
|
cd7f3ba820 | ||
|
|
e057d3ed9a | ||
|
|
5f04607a44 | ||
|
|
9440d13a08 | ||
|
|
85c4f1654e | ||
|
|
eed339cc64 | ||
|
|
3d1a23576a | ||
|
|
ed0e2e4bb5 | ||
|
|
954d0a0637 | ||
|
|
f2c8788602 | ||
|
|
49c63da27c | ||
|
|
24496d1856 | ||
|
|
991ebe09a2 | ||
|
|
85da4f6d85 | ||
|
|
5f065db991 | ||
|
|
ffb40586d7 | ||
|
|
fcfd87fd50 | ||
|
|
eb5b12aa7b | ||
|
|
f6e2438744 | ||
|
|
e3c7dc695d | ||
|
|
82d2025e6c | ||
|
|
91b82d78b8 | ||
|
|
b97e792893 | ||
|
|
13ac5ec7dc | ||
|
|
199f880936 | ||
|
|
ed86c207ba | ||
|
|
b4cf290f4d | ||
|
|
e526a6fd64 | ||
|
|
94cbbf169a | ||
|
|
2837ed16a7 | ||
|
|
68961deb6b | ||
|
|
ec54bfee98 | ||
|
|
385e97b76a | ||
|
|
cbd916877f | ||
|
|
38586034cd | ||
|
|
62b3ba2bff | ||
|
|
dd470b61b5 | ||
|
|
4fa92d2327 | ||
|
|
6f6c2db66d | ||
|
|
e6348cfa20 | ||
|
|
a006d1000a | ||
|
|
4a575e642f | ||
|
|
93525bc577 | ||
|
|
2cf0e9a723 | ||
|
|
c32164bfea | ||
|
|
714b0924e7 | ||
|
|
43079790a8 | ||
|
|
d03e61b625 | ||
|
|
2d760112a3 | ||
|
|
f46507ec72 | ||
|
|
e9e10bdc93 | ||
|
|
0386967a32 | ||
|
|
4900fc8b88 | ||
|
|
99294b5643 | ||
|
|
eb12bcb83c | ||
|
|
22a2e57642 | ||
|
|
5eaae06ceb | ||
|
|
ce7fc35349 | ||
|
|
8d4b5c83ae | ||
|
|
cbd3c56ca0 | ||
|
|
be6dad1424 | ||
|
|
298452fa7b | ||
|
|
4abbd7c35c | ||
|
|
c2f51c51ab | ||
|
|
255cff6664 | ||
|
|
8a9578bb0a | ||
|
|
8831f6cecc | ||
|
|
f3daa7e48b | ||
|
|
6163597958 | ||
|
|
f9e1222065 | ||
|
|
7d85de7c6c | ||
|
|
cf452c2300 | ||
|
|
72bd1d548d | ||
|
|
4556f4dee6 | ||
|
|
3dfbd3165a | ||
|
|
02b8e02131 | ||
|
|
087ded9f9e | ||
|
|
21f122ee82 | ||
|
|
d60a7e8c94 | ||
|
|
b8981c249f | ||
|
|
e71275a0dc | ||
|
|
4fb0db7a1e | ||
|
|
1e9beedc77 | ||
|
|
4a4a0653ef | ||
|
|
c80a900277 | ||
|
|
6fb0394d96 | ||
|
|
a6a7712039 | ||
|
|
dd0687ba29 | ||
|
|
9cb87a5333 | ||
|
|
8ec93d84a0 | ||
|
|
1d38715db9 | ||
|
|
6225c4eb35 | ||
|
|
e58ce2fbe6 | ||
|
|
8881d62e78 | ||
|
|
effb2a1265 | ||
|
|
ab387473b5 | ||
|
|
3cf6079b70 | ||
|
|
53c655bb53 | ||
|
|
87952463c2 | ||
|
|
3a8a63a49a | ||
|
|
debe115044 | ||
|
|
554d2808fd | ||
|
|
12b2c89a25 | ||
|
|
a66fc3a07e | ||
|
|
7b3705cab0 | ||
|
|
8e99e5f5e8 | ||
|
|
c5ba5370bb | ||
|
|
464dec9810 | ||
|
|
c2e2ec8803 | ||
|
|
37378e2360 | ||
|
|
678385d90c | ||
|
|
4c461f087f | ||
|
|
88a2b69980 | ||
|
|
1f57792da7 | ||
|
|
9bb4c45a23 | ||
|
|
75fd19f491 | ||
|
|
0ac16bdeb7 | ||
|
|
223ee41e10 | ||
|
|
c126ded82e | ||
|
|
0edf78b7fd | ||
|
|
5af3580987 | ||
|
|
343cb6f97a | ||
|
|
023c8ac13e | ||
|
|
c385eed795 | ||
|
|
ee5fdd789f | ||
|
|
df1e400f4e | ||
|
|
6c9c298478 | ||
|
|
7106ee150d | ||
|
|
03e2287f80 | ||
|
|
2edcd41e24 | ||
|
|
0fe043bd99 | ||
|
|
6686f5240d | ||
|
|
2936facf0f | ||
|
|
cc208f2c43 | ||
|
|
9a0fc231e5 | ||
|
|
bfc0ae62ec | ||
|
|
5e7d8d97f2 | ||
|
|
70ceb16ed6 | ||
|
|
f162fa639f | ||
|
|
f000c72546 | ||
|
|
32c01f931c | ||
|
|
d0121e2b9d | ||
|
|
1caab8ce1d | ||
|
|
878be435a1 | ||
|
|
6a68ae989e | ||
|
|
00993da781 | ||
|
|
e9ef67e402 | ||
|
|
83ebfececf | ||
|
|
ec8bf6251f | ||
|
|
1b2874b3a5 | ||
|
|
0ac1053a71 | ||
|
|
224d270952 | ||
|
|
c6137545cd | ||
|
|
335417f9f4 | ||
|
|
cb797223ed | ||
|
|
3a2a0313ac | ||
|
|
e221a5a73a | ||
|
|
2b7aaf095f | ||
|
|
6f01e7b8d8 | ||
|
|
d594419200 | ||
|
|
bf50e3f898 | ||
|
|
d434f1781f | ||
|
|
3f311a45ef | ||
|
|
9293b7796e | ||
|
|
b1e7883526 | ||
|
|
212800155b | ||
|
|
c241bf2104 | ||
|
|
bda61f32f3 |
2
.github/helper/install_dependencies.sh
vendored
2
.github/helper/install_dependencies.sh
vendored
@@ -5,7 +5,7 @@ echo "Setting Up System Dependencies..."
|
||||
|
||||
sudo apt update
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt-get install libcups2-dev redis-server mariadb-client
|
||||
sudo apt-get install libcups2-dev redis-server mariadb-client libmariadb-dev
|
||||
|
||||
install_wkhtmltopdf() {
|
||||
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
||||
|
||||
2
.github/workflows/make_release_pr.yml
vendored
2
.github/workflows/make_release_pr.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Create weekly release
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 4 15 * *'
|
||||
- cron: '30 3 * * 3'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -118,6 +118,10 @@ Replace the following parameters with your values:
|
||||
|
||||
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
|
||||
|
||||
**Note:** To avoid a `404 Page Not Found` error:
|
||||
- If hosting on a **public server**, make sure your DNS **A record** points to your server's IP.
|
||||
- If hosting **locally**, map your domain to `127.0.0.1` in your `/etc/hosts` file:
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -23,8 +23,8 @@ describe("Batch Creation", () => {
|
||||
const randomEmail = `testuser_${dateNow}@example.com`;
|
||||
const randomName = `Test User ${dateNow}`;
|
||||
|
||||
cy.get("input[placeholder='Email']").type(randomEmail);
|
||||
cy.get("input[placeholder='First Name']").type(randomName);
|
||||
cy.get("input[placeholder='jane@doe.com']").type(randomEmail);
|
||||
cy.get("input[placeholder='Jane']").type(randomName);
|
||||
cy.get("button").contains("Add").click();
|
||||
|
||||
// Add evaluator
|
||||
@@ -39,7 +39,7 @@ describe("Batch Creation", () => {
|
||||
.click();
|
||||
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||
|
||||
cy.get("input[placeholder='Email']").type(randomEvaluator);
|
||||
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.get("div").contains(randomEvaluator).should("be.visible").click();
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("Batch Creation", () => {
|
||||
cy.closeOnboardingModal();
|
||||
|
||||
// Create a batch
|
||||
cy.get("button").contains("New").click();
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/batches/new/edit");
|
||||
cy.get("label").contains("Title").type("Test Batch");
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("Course Creation", () => {
|
||||
cy.closeOnboardingModal();
|
||||
|
||||
// Create a course
|
||||
cy.get("button").contains("New").click();
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("Course Creation", () => {
|
||||
cy.get("div").contains(
|
||||
"Test Course Short Introduction to test the UI"
|
||||
);
|
||||
cy.get(".course-image")
|
||||
cy.get(".bg-cover")
|
||||
.invoke("css", "background-image")
|
||||
.should("include", "/files/profile");
|
||||
});
|
||||
@@ -140,6 +140,7 @@ describe("Course Creation", () => {
|
||||
);
|
||||
|
||||
// Add Discussion
|
||||
cy.get("span").contains("Community").click();
|
||||
cy.button("New Question").click();
|
||||
cy.wait(500);
|
||||
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
||||
|
||||
Submodule frappe-ui updated: fd5252663b...333dce1a4d
10
frontend/auto-imports.d.ts
vendored
Normal file
10
frontend/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
13
frontend/components.d.ts
vendored
13
frontend/components.d.ts
vendored
@@ -19,6 +19,10 @@ declare module 'vue' {
|
||||
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
||||
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
||||
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
|
||||
BadgeAssignmentForm: typeof import('./src/components/Settings/BadgeAssignmentForm.vue')['default']
|
||||
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
|
||||
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
|
||||
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
|
||||
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
|
||||
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
||||
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
||||
@@ -32,12 +36,16 @@ declare module 'vue' {
|
||||
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
|
||||
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
||||
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
|
||||
ChildTable: typeof import('./src/components/Controls/ChildTable.vue')['default']
|
||||
Code: typeof import('./src/components/Controls/Code.vue')['default']
|
||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
|
||||
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
|
||||
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
||||
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
||||
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
||||
@@ -58,6 +66,7 @@ declare module 'vue' {
|
||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
||||
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
|
||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
||||
JobCard: typeof import('./src/components/JobCard.vue')['default']
|
||||
@@ -73,6 +82,7 @@ declare module 'vue' {
|
||||
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
||||
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
|
||||
@@ -83,6 +93,7 @@ declare module 'vue' {
|
||||
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
||||
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
|
||||
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
|
||||
RelatedCourses: typeof import('./src/components/RelatedCourses.vue')['default']
|
||||
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
@@ -95,10 +106,12 @@ declare module 'vue' {
|
||||
Tags: typeof import('./src/components/Tags.vue')['default']
|
||||
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
||||
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
||||
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
|
||||
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
|
||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
||||
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
|
||||
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
||||
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@editorjs/checklist": "^1.6.0",
|
||||
"@editorjs/code": "^2.9.0",
|
||||
"@editorjs/editorjs": "^2.29.0",
|
||||
@@ -24,10 +28,10 @@
|
||||
"ace-builds": "^1.36.2",
|
||||
"apexcharts": "^4.3.0",
|
||||
"chart.js": "^4.4.1",
|
||||
"codemirror-editor-vue3": "^2.8.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"dayjs": "^1.11.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.147",
|
||||
"frappe-ui": "0.1.173",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
@@ -35,9 +39,11 @@
|
||||
"plyr": "^3.7.8",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss": "3.4.15",
|
||||
"thememirror": "^2.0.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vue": "^3.4.23",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-draggable-next": "^2.2.1",
|
||||
"vue-router": "^4.0.12",
|
||||
"vue3-apexcharts": "^1.8.0",
|
||||
|
||||
BIN
frontend/public/Remove.mp4
Normal file
BIN
frontend/public/Remove.mp4
Normal file
Binary file not shown.
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<FrappeUIProvider>
|
||||
<Layout>
|
||||
<router-view />
|
||||
<div class="text-base">
|
||||
<router-view />
|
||||
</div>
|
||||
</Layout>
|
||||
<Dialogs />
|
||||
</FrappeUIProvider>
|
||||
|
||||
@@ -191,7 +191,7 @@ import {
|
||||
h,
|
||||
onUnmounted,
|
||||
} from 'vue'
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { getSidebarLinks } from '@/utils'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSidebar } from '@/stores/sidebar'
|
||||
@@ -225,7 +225,7 @@ import {
|
||||
IntermediateStepModal,
|
||||
} from 'frappe-ui/frappe'
|
||||
|
||||
const { user, sidebarSettings } = sessionStore()
|
||||
const { user } = sessionStore()
|
||||
const { userResource } = usersStore()
|
||||
let sidebarStore = useSidebar()
|
||||
const socket = inject('$socket')
|
||||
@@ -236,6 +236,7 @@ const isModerator = ref(false)
|
||||
const isInstructor = ref(false)
|
||||
const pageToEdit = ref(null)
|
||||
const settingsStore = useSettings()
|
||||
const { sidebarSettings } = settingsStore
|
||||
const showOnboarding = ref(false)
|
||||
const showIntermediateModal = ref(false)
|
||||
const currentStep = ref({})
|
||||
@@ -313,7 +314,7 @@ const addNotifications = () => {
|
||||
|
||||
const addQuizzes = () => {
|
||||
if (isInstructor.value || isModerator.value) {
|
||||
sidebarLinks.value.push({
|
||||
sidebarLinks.value.splice(4, 0, {
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
@@ -329,7 +330,7 @@ const addQuizzes = () => {
|
||||
|
||||
const addAssignments = () => {
|
||||
if (isInstructor.value || isModerator.value) {
|
||||
sidebarLinks.value.push({
|
||||
sidebarLinks.value.splice(5, 0, {
|
||||
label: 'Assignments',
|
||||
icon: 'Pencil',
|
||||
to: 'Assignments',
|
||||
@@ -343,6 +344,22 @@ const addAssignments = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const addProgrammingExercises = () => {
|
||||
if (isInstructor.value || isModerator.value) {
|
||||
sidebarLinks.value.splice(3, 0, {
|
||||
label: 'Programming Exercises',
|
||||
icon: 'Code',
|
||||
to: 'ProgrammingExercises',
|
||||
activeFor: [
|
||||
'ProgrammingExercises',
|
||||
'ProgrammingExerciseForm',
|
||||
'ProgrammingExerciseSubmissions',
|
||||
'ProgrammingExerciseSubmission',
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addPrograms = () => {
|
||||
let activeFor = ['Programs', 'ProgramForm']
|
||||
let index = 1
|
||||
@@ -626,6 +643,7 @@ watch(userResource, () => {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addPrograms()
|
||||
addProgrammingExercises()
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
setUpOnboarding()
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title:
|
||||
type == 'quiz'
|
||||
? __('Add a quiz to your lesson')
|
||||
: __('Add an assignment to your lesson'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: () => {
|
||||
addAssessment()
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-5 space-y-4">
|
||||
<div v-if="type == 'quiz'" class="text-lg font-semibold">
|
||||
{{ __('Add a quiz to your lesson') }}
|
||||
</div>
|
||||
<div v-else class="text-lg font-semibold">
|
||||
{{ __('Add an assignment to your lesson') }}
|
||||
</div>
|
||||
<template #body-content>
|
||||
<div class="">
|
||||
<div>
|
||||
<Link
|
||||
v-if="type == 'quiz'"
|
||||
@@ -29,17 +36,12 @@
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<Button variant="solid" @click="addAssessment()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, Button } from 'frappe-ui'
|
||||
import { Dialog } from 'frappe-ui'
|
||||
import { onMounted, ref, nextTick } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'assessment_type'">
|
||||
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
|
||||
{{ getAssessmentTypeLabel(row[column.key]) }}
|
||||
</div>
|
||||
<div v-else-if="column.key == 'title'">
|
||||
{{ row[column.key] }}
|
||||
@@ -172,6 +172,24 @@ const getRowRoute = (row) => {
|
||||
},
|
||||
}
|
||||
}
|
||||
} else if (row.assessment_type == 'LMS Programming Exercise') {
|
||||
if (row.submission) {
|
||||
return {
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: {
|
||||
exerciseID: row.assessment_name,
|
||||
submissionID: row.submission.name,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: {
|
||||
exerciseID: row.assessment_name,
|
||||
submissionID: 'new',
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: 'QuizPage',
|
||||
@@ -213,7 +231,7 @@ const getAssessmentColumns = () => {
|
||||
}
|
||||
|
||||
const getStatusTheme = (status) => {
|
||||
if (status === 'Pass') {
|
||||
if (status === 'Pass' || status === 'Passed') {
|
||||
return 'green'
|
||||
} else if (status === 'Not Graded') {
|
||||
return 'orange'
|
||||
@@ -221,4 +239,14 @@ const getStatusTheme = (status) => {
|
||||
return 'red'
|
||||
}
|
||||
}
|
||||
|
||||
const getAssessmentTypeLabel = (type) => {
|
||||
if (type == 'LMS Assignment') {
|
||||
return __('Assignment')
|
||||
} else if (type == 'LMS Quiz') {
|
||||
return __('Quiz')
|
||||
} else if (type == 'LMS Programming Exercise') {
|
||||
return __('Programming Exercise')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -70,9 +70,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge } from 'frappe-ui'
|
||||
import { formatTime } from '../utils'
|
||||
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Clock, Globe } from 'lucide-vue-next'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
:courses="batch.data.courses"
|
||||
/>
|
||||
<Assessments :batch="batch.data.name" />
|
||||
<StudentHeatmap />
|
||||
<!-- <StudentHeatmap /> -->
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<template #prefix>
|
||||
<Settings v-if="isModerator" class="size-4 stroke-1.5" />
|
||||
<LogIn v-else class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||
</span>
|
||||
@@ -85,6 +89,9 @@
|
||||
"
|
||||
>
|
||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||
<template #prefix>
|
||||
<CreditCard class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Register Now') }}
|
||||
</span>
|
||||
@@ -100,6 +107,9 @@
|
||||
"
|
||||
@click="enrollInBatch()"
|
||||
>
|
||||
<template #prefix>
|
||||
<GraduationCap class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
<router-link
|
||||
@@ -112,6 +122,9 @@
|
||||
}"
|
||||
>
|
||||
<Button class="w-full mt-2">
|
||||
<template #prefix>
|
||||
<Pencil class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
@@ -122,8 +135,17 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
import { Badge, Button, createResource, toast } from 'frappe-ui'
|
||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
||||
import { Button, createResource, toast } from 'frappe-ui'
|
||||
import {
|
||||
BookOpen,
|
||||
Clock,
|
||||
CreditCard,
|
||||
Globe,
|
||||
GraduationCap,
|
||||
LogIn,
|
||||
Pencil,
|
||||
Settings,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -1,127 +1,140 @@
|
||||
<template>
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="() => togglePopover()"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span class="text-base leading-5 text-ink-gray-4" v-else>
|
||||
{{ placeholder || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2">
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<div>
|
||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __(label) }}
|
||||
<span class="text-ink-red-3" v-if="attrs.required">*</span>
|
||||
</div>
|
||||
<Combobox
|
||||
v-model="selectedValue"
|
||||
nullable
|
||||
v-slot="{ open: isComboboxOpen }"
|
||||
>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="() => togglePopover()"
|
||||
:disabled="attrs.readonly"
|
||||
>
|
||||
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
|
||||
<div class="flex items-center">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span class="text-base leading-5 text-ink-gray-4" v-else>
|
||||
{{ placeholder || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||
>
|
||||
<div
|
||||
class="mt-1.5"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
v-show="group.items.length > 0"
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||
class="mt-1.5"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
v-show="group.items.length > 0"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
as="template"
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
as="template"
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col space-y-1 text-ink-gray-8">
|
||||
<div>
|
||||
{{ option.label }}
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div class="flex flex-col space-y-1 text-ink-gray-8">
|
||||
<div>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
option.description &&
|
||||
option.description != option.label
|
||||
"
|
||||
class="text-xs text-ink-gray-7"
|
||||
v-html="option.description"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
option.description &&
|
||||
option.description != option.label
|
||||
"
|
||||
class="text-xs text-ink-gray-7"
|
||||
v-html="option.description"
|
||||
></div>
|
||||
</div>
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
No results found
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
No results found
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -148,6 +161,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'md',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
|
||||
149
frontend/src/components/Controls/ChildTable.vue
Normal file
149
frontend/src/components/Controls/ChildTable.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="overflow-x-auto border rounded-md">
|
||||
<div
|
||||
class="grid items-center space-x-4 p-2 border-b"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<div
|
||||
v-for="(column, index) in columns"
|
||||
:key="index"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ column }}
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="rowIndex"
|
||||
class="grid items-center space-x-4 p-2"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<template v-for="key in Object.keys(row)" :key="key">
|
||||
<input
|
||||
v-if="showKey(key)"
|
||||
v-model="row[key]"
|
||||
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="relative" ref="menuRef">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
|
||||
>
|
||||
<template #icon>
|
||||
<Ellipsis
|
||||
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="menuOpenIndex === rowIndex"
|
||||
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
|
||||
>
|
||||
<button
|
||||
@click="deleteRow(rowIndex)"
|
||||
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||
>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Delete') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<Button @click="addRow">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 text-ink-gray-7" />
|
||||
</template>
|
||||
{{ __('Add Row') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const rows = defineModel<Cell[][]>()
|
||||
const menuRef = ref(null)
|
||||
const menuOpenIndex = ref<number | null>(null)
|
||||
const menuTopPosition = ref<string>('')
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Cell[][]): void
|
||||
}>()
|
||||
|
||||
type Cell = {
|
||||
value: string
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: Cell[][]
|
||||
columns?: string[]
|
||||
label?: string
|
||||
}>(),
|
||||
{
|
||||
columns: [],
|
||||
}
|
||||
)
|
||||
|
||||
const columns = ref(props.columns)
|
||||
|
||||
watch(rows, () => {
|
||||
if (rows.value?.length < 1) {
|
||||
addRow()
|
||||
}
|
||||
})
|
||||
|
||||
const addRow = () => {
|
||||
if (!rows.value) {
|
||||
rows.value = []
|
||||
}
|
||||
let newRow: { [key: string]: string } = {}
|
||||
columns.value.forEach((column: any) => {
|
||||
newRow[column.toLowerCase().split(' ').join('_')] = ''
|
||||
})
|
||||
rows.value.push(newRow)
|
||||
emit('update:modelValue', rows.value)
|
||||
}
|
||||
|
||||
const deleteRow = (index: number) => {
|
||||
rows.value.splice(index, 1)
|
||||
emit('update:modelValue', rows.value)
|
||||
}
|
||||
|
||||
const getGridTemplateColumns = () => {
|
||||
return [...Array(columns.value.length).fill('1fr'), '0.25fr'].join(' ')
|
||||
}
|
||||
|
||||
const toggleMenu = (index: number, event: MouseEvent) => {
|
||||
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
|
||||
menuTopPosition.value = `${event.clientY + 10}px`
|
||||
}
|
||||
|
||||
onClickOutside(menuRef, () => {
|
||||
menuOpenIndex.value = null
|
||||
})
|
||||
|
||||
const showKey = (key: string) => {
|
||||
let columnsLower = columns.value.map((col) =>
|
||||
col.toLowerCase().split(' ').join('_')
|
||||
)
|
||||
return columnsLower.includes(key)
|
||||
}
|
||||
</script>
|
||||
162
frontend/src/components/Controls/Code.vue
Normal file
162
frontend/src/components/Controls/Code.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-1.5">
|
||||
<div v-if="label" class="text-xs text-ink-gray-5">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<codemirror
|
||||
v-model="code"
|
||||
:extensions="extensions"
|
||||
:tab-size="2"
|
||||
:autofocus="autofocus"
|
||||
:indent-with-tab="true"
|
||||
:style="{ height: height, maxHeight: maxHeight }"
|
||||
:disabled="readonly"
|
||||
@blur="emitEditorValue"
|
||||
:class="{
|
||||
'border border-outline-gray-1': showBorder,
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
v-if="showSaveButton"
|
||||
@click="emit('save', code)"
|
||||
class="mt-3 w-full text-base"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed, watch } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
import { Codemirror } from 'vue-codemirror'
|
||||
import { autocompletion, closeBrackets } from '@codemirror/autocomplete'
|
||||
import { LanguageSupport } from '@codemirror/language'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { tomorrow } from 'thememirror'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
language: 'json' | 'javascript' | 'html' | 'css' | 'python'
|
||||
modelValue: string | object | Array<string | object> | null
|
||||
height?: string
|
||||
maxHeight?: string
|
||||
autofocus?: boolean
|
||||
showSaveButton?: boolean
|
||||
showLineNumbers?: boolean
|
||||
completions?: Function | null
|
||||
label?: string
|
||||
showBorder?: boolean
|
||||
required?: boolean
|
||||
readonly?: boolean
|
||||
}>(),
|
||||
{
|
||||
language: 'javascript',
|
||||
modelValue: null,
|
||||
height: 'auto',
|
||||
maxHeight: '250px',
|
||||
showLineNumbers: true,
|
||||
completions: null,
|
||||
}
|
||||
)
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
|
||||
const code = ref<string>('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
code.value =
|
||||
typeof newVal === 'string' ? newVal : JSON.stringify(newVal, null, 2)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(code, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const errorMessage = ref('')
|
||||
const emitEditorValue = () => {
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
let value = code.value || ''
|
||||
|
||||
if (!props.showSaveButton && !props.readonly) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error while parsing JSON for editor', e)
|
||||
errorMessage.value = `Invalid object/JSON: ${e.message}`
|
||||
}
|
||||
}
|
||||
|
||||
const languageExtension = ref<LanguageSupport>()
|
||||
const autocompleteExtension = ref()
|
||||
|
||||
async function setLanguageExtension() {
|
||||
const importMap = {
|
||||
json: () => import('@codemirror/lang-json'),
|
||||
javascript: () => import('@codemirror/lang-javascript'),
|
||||
html: () => import('@codemirror/lang-html'),
|
||||
css: () => import('@codemirror/lang-css'),
|
||||
python: () => import('@codemirror/lang-python'),
|
||||
}
|
||||
|
||||
const languageImport = importMap[props.language]
|
||||
if (!languageImport) return
|
||||
|
||||
const module = await languageImport()
|
||||
languageExtension.value = (module as any)[props.language]()
|
||||
|
||||
if (props.completions) {
|
||||
const languageData = (module as any)[`${props.language}Language`]
|
||||
autocompleteExtension.value = languageData.data.of({
|
||||
autocomplete: props.completions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await setLanguageExtension()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.language,
|
||||
async () => {
|
||||
await setLanguageExtension()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const extensions = computed(() => {
|
||||
const baseExtensions = [
|
||||
closeBrackets(),
|
||||
tomorrow,
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
display: props.showLineNumbers ? 'flex' : 'none',
|
||||
},
|
||||
}),
|
||||
]
|
||||
if (languageExtension.value) {
|
||||
baseExtensions.push(languageExtension.value)
|
||||
}
|
||||
if (autocompleteExtension.value) {
|
||||
baseExtensions.push(autocompleteExtension.value)
|
||||
}
|
||||
const autocompletionOptions = {
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 10,
|
||||
closeOnBlur: false,
|
||||
icons: false,
|
||||
optionClass: () => 'flex h-7 !px-2 items-center rounded !text-gray-600',
|
||||
}
|
||||
baseExtensions.push(autocompletion(autocompletionOptions))
|
||||
return baseExtensions
|
||||
})
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@
|
||||
height: height,
|
||||
}"
|
||||
>
|
||||
<span class="text-xs text-ink-gray-7" v-if="label">
|
||||
<span class="text-xs text-ink-gray-7 mb-1" v-if="label">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div
|
||||
|
||||
108
frontend/src/components/Controls/ColorSwatches.vue
Normal file
108
frontend/src/components/Controls/ColorSwatches.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Popover placement="bottom" class="!block">
|
||||
<template #target="{ togglePopover, isOpen }">
|
||||
<div class="space-y-2">
|
||||
<FormControl
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:placeholder="__('Set Color')"
|
||||
@focus="togglePopover"
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="(val: string) => emit('update:modelValue', val)"
|
||||
>
|
||||
<template #prefix>
|
||||
<div
|
||||
class="size-4 rounded-full"
|
||||
:style="
|
||||
modelValue
|
||||
? {
|
||||
backgroundColor:
|
||||
theme.backgroundColor[modelValue.toLowerCase()][400],
|
||||
}
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<Palette
|
||||
v-if="!modelValue"
|
||||
class="size-4 stroke-1.5 text-ink-gray-5"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<Button variant="ghost">
|
||||
<X
|
||||
class="size-3 text-ink-gray-5"
|
||||
@click="emit('update:modelValue', null)"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ close }">
|
||||
<div class="rounded-lg bg-surface-white p-3 border w-fit mt-2">
|
||||
<div class="text-xs text-ink-gray-5 mb-1.5">
|
||||
{{ __('Swatches') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
class="size-5 rounded-full cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
theme.backgroundColor[color.toLowerCase()][400],
|
||||
}"
|
||||
@click="
|
||||
(e) => {
|
||||
emit('update:modelValue', color)
|
||||
close()
|
||||
emit('change', color)
|
||||
}
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<div class="text-sm text-ink-gray-5 mt-2">
|
||||
{{ description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl, Popover } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { Palette, X } from 'lucide-vue-next'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
label: string
|
||||
description?: string
|
||||
}>()
|
||||
|
||||
const colors = computed(() => {
|
||||
return [
|
||||
'Red',
|
||||
'Blue',
|
||||
'Green',
|
||||
'Amber',
|
||||
'Purple',
|
||||
'Cyan',
|
||||
'Orange',
|
||||
'Violet',
|
||||
'Pink',
|
||||
'Teal',
|
||||
'Gray',
|
||||
'Yellow',
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -12,6 +12,7 @@
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:filterable="false"
|
||||
:readonly="attrs.readonly"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
<slot name="target" v-bind="{ open, togglePopover }" />
|
||||
|
||||
76
frontend/src/components/Controls/Uploader.vue
Normal file
76
frontend/src/components/Controls/Uploader.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="mb-4">
|
||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __(label) }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!modelValue"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file: File) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-7 px-20">
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div
|
||||
v-if="description"
|
||||
class="mt-2 text-ink-gray-5 text-sm leading-5"
|
||||
>
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { validateFile } from '@/utils'
|
||||
import { Button, FileUploader } from 'frappe-ui'
|
||||
import { Image } from 'lucide-vue-next'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
description?: string
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
label: '',
|
||||
description: '',
|
||||
}
|
||||
)
|
||||
|
||||
const saveImage = (file: any) => {
|
||||
emit('update:modelValue', file.file_url)
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
</script>
|
||||
@@ -1,41 +1,51 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="course.title"
|
||||
class="flex flex-col h-full rounded-md border-2 overflow-auto"
|
||||
class="flex flex-col h-full rounded-md border-2 overflow-auto text-ink-gray-9"
|
||||
style="min-height: 350px"
|
||||
>
|
||||
<div
|
||||
class="course-image"
|
||||
:class="{ 'default-image': !course.image }"
|
||||
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
|
||||
class="w-[100%] h-[168px] bg-cover bg-center bg-no-repeat"
|
||||
:style="
|
||||
course.image
|
||||
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
||||
: {
|
||||
backgroundImage: getGradientColor(),
|
||||
backgroundBlendMode: 'screen',
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
||||
<Badge
|
||||
<div
|
||||
v-if="course.featured"
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="md"
|
||||
class="mb-1 mr-1"
|
||||
class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md mr-1 mb-1"
|
||||
>
|
||||
{{ __('Featured') }}
|
||||
</Badge>
|
||||
<Star class="size-3 stroke-2" />
|
||||
<span>
|
||||
{{ __('Featured') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md mb-1 mr-1"
|
||||
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 mr-1"
|
||||
>
|
||||
{{ tag }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!course.image" class="image-placeholder">
|
||||
{{ course.title[0] }}
|
||||
</div>
|
||||
<!-- <div
|
||||
v-if="!course.image"
|
||||
class="flex items-center justify-center text-white flex-1 font-extrabold text-2xl my-auto px-5 text-center leading-6"
|
||||
:class="course.tags ? 'h-[80%]' : 'h-full'"
|
||||
>
|
||||
{{ course.title }}
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="flex flex-col flex-auto p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div v-if="course.lessons">
|
||||
<Tooltip :text="__('Lessons')">
|
||||
<span class="flex items-center text-ink-gray-7">
|
||||
<span class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
{{ course.lessons }}
|
||||
</span>
|
||||
@@ -44,8 +54,8 @@
|
||||
|
||||
<div v-if="course.enrollments">
|
||||
<Tooltip :text="__('Enrolled Students')">
|
||||
<span class="flex items-center text-ink-gray-7">
|
||||
<Users class="h-4 w-4 stroke-1. mr-1" />
|
||||
<span class="flex items-center">
|
||||
<Users class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
{{ course.enrollments }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -53,29 +63,22 @@
|
||||
|
||||
<div v-if="course.rating">
|
||||
<Tooltip :text="__('Average Rating')">
|
||||
<span class="flex items-center text-ink-gray-7">
|
||||
<span class="flex items-center">
|
||||
<Star class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
{{ course.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.status != 'Approved'">
|
||||
<Badge
|
||||
variant="subtle"
|
||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
||||
size="sm"
|
||||
>
|
||||
{{ course.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xl font-semibold leading-6 text-ink-gray-9">
|
||||
<div
|
||||
class="font-semibold leading-6"
|
||||
:class="course.title.length > 32 ? 'text-lg' : 'text-xl'"
|
||||
>
|
||||
{{ course.title }}
|
||||
</div>
|
||||
|
||||
<div class="short-introduction text-ink-gray-7 text-sm">
|
||||
<div class="short-introduction text-sm">
|
||||
{{ course.short_introduction }}
|
||||
</div>
|
||||
|
||||
@@ -84,11 +87,8 @@
|
||||
:progress="course.membership.progress"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="user && course.membership"
|
||||
class="text-sm text-ink-gray-7 mt-2 mb-4"
|
||||
>
|
||||
{{ Math.ceil(course.membership.progress) }}% completed
|
||||
<div v-if="user && course.membership" class="text-sm mt-2 mb-4">
|
||||
{{ Math.ceil(course.membership.progress) }}% {{ __('completed') }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-auto">
|
||||
@@ -108,21 +108,23 @@
|
||||
<div v-if="course.paid_course" class="font-semibold">
|
||||
{{ course.price }}
|
||||
</div>
|
||||
<div
|
||||
|
||||
<Tooltip
|
||||
v-if="course.paid_certificate || course.enable_certification"
|
||||
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
|
||||
:text="__('Get Certified')"
|
||||
>
|
||||
{{ __('Certification') }}
|
||||
</div>
|
||||
<GraduationCap class="size-5 stroke-1.5 text-ink-gray-7" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
import { BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Badge, Tooltip } from 'frappe-ui'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { theme } from '@/utils/theme'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
@@ -134,16 +136,24 @@ const props = defineProps({
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const getGradientColor = () => {
|
||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||
let colorMap = theme.backgroundColor[color]
|
||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
|
||||
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
|
||||
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
|
||||
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
|
||||
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
|
||||
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
|
||||
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
|
||||
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
|
||||
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.course-image {
|
||||
height: 168px;
|
||||
width: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.course-card-pills {
|
||||
background: #ffffff;
|
||||
margin-left: 0;
|
||||
@@ -157,14 +167,6 @@ const props = defineProps({
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.default-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: theme('colors.green.100');
|
||||
color: theme('colors.green.600');
|
||||
}
|
||||
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -173,14 +175,7 @@ const props = defineProps({
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
.image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
font-size: 5rem;
|
||||
color: theme('colors.gray.700');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.avatar-group.overlap .avatar + .avatar {
|
||||
margin-left: calc(-8px);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="border-2 rounded-md min-w-80">
|
||||
<div class="border-2 rounded-md min-w-80 max-w-sm">
|
||||
<iframe
|
||||
v-if="course.data.video_link"
|
||||
:src="video_link"
|
||||
@@ -26,6 +26,9 @@
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" size="md" class="w-full">
|
||||
<template #prefix>
|
||||
<BookText class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Continue Learning') }}
|
||||
</span>
|
||||
@@ -44,6 +47,9 @@
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" size="md" class="w-full">
|
||||
<template #prefix>
|
||||
<CreditCard class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Buy this course') }}
|
||||
</span>
|
||||
@@ -57,12 +63,15 @@
|
||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-else
|
||||
v-else-if="!user.data?.is_moderator && !is_instructor()"
|
||||
@click="enrollStudent()"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
size="md"
|
||||
>
|
||||
<template #prefix>
|
||||
<BookText class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Start Learning') }}
|
||||
</span>
|
||||
@@ -74,8 +83,22 @@
|
||||
class="w-full mt-2"
|
||||
size="md"
|
||||
>
|
||||
<template #prefix>
|
||||
<GraduationCap class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Get Certificate') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="user.data?.is_moderator || is_instructor()"
|
||||
class="w-full mt-2"
|
||||
size="md"
|
||||
@click="showProgressSummary"
|
||||
>
|
||||
<template #prefix>
|
||||
<TrendingUp class="size-4 stroke-1.5" />
|
||||
{{ __('Progress Summary') }}
|
||||
</template>
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="user?.data?.is_moderator || is_instructor()"
|
||||
:to="{
|
||||
@@ -86,6 +109,9 @@
|
||||
}"
|
||||
>
|
||||
<Button variant="subtle" class="w-full mt-2" size="md">
|
||||
<template #prefix>
|
||||
<Pencil class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
@@ -142,18 +168,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseProgressSummary
|
||||
v-model="showProgressModal"
|
||||
:courseName="course.data.name"
|
||||
:enrollments="course.data.enrollments"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
|
||||
import { computed, inject } from 'vue'
|
||||
import {
|
||||
BookOpen,
|
||||
BookText,
|
||||
CreditCard,
|
||||
GraduationCap,
|
||||
Pencil,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
|
||||
import { formatAmount } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const showProgressModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
@@ -246,4 +288,8 @@ const fetchCertificate = () => {
|
||||
member: user.data?.name,
|
||||
})
|
||||
}
|
||||
|
||||
const showProgressSummary = () => {
|
||||
showProgressModal.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="text-ink-gray-7">
|
||||
<div class="">
|
||||
<span v-if="instructors?.length == 1">
|
||||
<router-link
|
||||
:to="{
|
||||
@@ -19,7 +19,7 @@
|
||||
>
|
||||
{{ instructors[0].first_name }}
|
||||
</router-link>
|
||||
and
|
||||
{{ __('and') }}
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
@@ -38,7 +38,7 @@
|
||||
>
|
||||
{{ instructors[0].first_name }}
|
||||
</router-link>
|
||||
and {{ instructors?.length - 1 }} others
|
||||
{{ __('and') }} {{ instructors?.length - 1 }} {{ __('others') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,119 +23,135 @@
|
||||
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
|
||||
}"
|
||||
>
|
||||
<Disclosure
|
||||
v-slot="{ open }"
|
||||
v-for="(chapter, index) in outline.data"
|
||||
:key="chapter.name"
|
||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||
<Draggable
|
||||
:list="outline.data"
|
||||
:disabled="!allowEdit"
|
||||
item-key="name"
|
||||
group="chapters"
|
||||
@end="updateChapterOrder"
|
||||
>
|
||||
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
|
||||
<ChevronRight
|
||||
:class="{
|
||||
'rotate-90 transform duration-200': open,
|
||||
'duration-200': !open,
|
||||
hidden: chapter.is_scorm_package,
|
||||
open: index == 1,
|
||||
}"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1"
|
||||
/>
|
||||
<div
|
||||
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
|
||||
@click="redirectToChapter(chapter)"
|
||||
>
|
||||
{{ chapter.title }}
|
||||
</div>
|
||||
<div class="flex ml-auto space-x-4">
|
||||
<Tooltip :text="__('Edit Chapter')" placement="bottom">
|
||||
<FilePenLine
|
||||
v-if="allowEdit"
|
||||
@click.prevent="openChapterModal(chapter)"
|
||||
class="h-4 w-4 text-ink-gray-9 invisible group-hover:visible"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Delete Chapter')" placement="bottom">
|
||||
<Trash2
|
||||
v-if="allowEdit"
|
||||
@click.prevent="trashChapter(chapter.name)"
|
||||
class="h-4 w-4 text-ink-red-3 invisible group-hover:visible"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel v-if="!chapter.is_scorm_package">
|
||||
<Draggable
|
||||
v-if="!chapter.is_scorm_package"
|
||||
:list="chapter.lessons"
|
||||
:disabled="!allowEdit"
|
||||
item-key="name"
|
||||
group="items"
|
||||
@end="updateOutline"
|
||||
:data-chapter="chapter.name"
|
||||
>
|
||||
<template #item="{ element: lesson }">
|
||||
<div
|
||||
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
|
||||
:class="
|
||||
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
|
||||
"
|
||||
<template #item="{ element: chapter, index }">
|
||||
<div class="chapter-item">
|
||||
<Disclosure
|
||||
v-slot="{ open }"
|
||||
:key="chapter.name"
|
||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||
>
|
||||
<DisclosureButton
|
||||
ref=""
|
||||
class="flex items-center w-full p-2 group"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.number.split('.')[0],
|
||||
lessonNumber: lesson.number.split('.')[1],
|
||||
},
|
||||
<ChevronRight
|
||||
:class="{
|
||||
'rotate-90 transform duration-200': open,
|
||||
'duration-200': !open,
|
||||
hidden: chapter.is_scorm_package,
|
||||
open: index == 1,
|
||||
}"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1"
|
||||
/>
|
||||
<div
|
||||
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
|
||||
@click="redirectToChapter(chapter)"
|
||||
>
|
||||
<div class="flex items-center text-sm leading-5 group">
|
||||
<MonitorPlay
|
||||
v-if="lesson.icon === 'icon-youtube'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
{{ chapter.title }}
|
||||
</div>
|
||||
<div class="flex ml-auto space-x-4">
|
||||
<Tooltip :text="__('Edit Chapter')" placement="bottom">
|
||||
<FilePenLine
|
||||
v-if="allowEdit"
|
||||
@click.prevent="openChapterModal(chapter)"
|
||||
class="h-4 w-4 text-ink-gray-9 invisible group-hover:visible"
|
||||
/>
|
||||
<HelpCircle
|
||||
v-else-if="lesson.icon === 'icon-quiz'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<FileText
|
||||
v-else-if="lesson.icon === 'icon-list'"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
||||
/>
|
||||
{{ lesson.title }}
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Delete Chapter')" placement="bottom">
|
||||
<Trash2
|
||||
v-if="allowEdit"
|
||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
||||
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
|
||||
@click.prevent="trashChapter(chapter.name)"
|
||||
class="h-4 w-4 text-ink-red-3 invisible group-hover:visible"
|
||||
/>
|
||||
<Check
|
||||
v-if="lesson.is_complete"
|
||||
class="h-4 w-4 text-green-700 ml-2"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
||||
<router-link
|
||||
v-if="!chapter.is_scorm_package"
|
||||
:to="{
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: chapter.idx,
|
||||
lessonNumber: chapter.lessons.length + 1,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Add Lesson') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel v-if="!chapter.is_scorm_package">
|
||||
<Draggable
|
||||
v-if="!chapter.is_scorm_package"
|
||||
:list="chapter.lessons"
|
||||
:disabled="!allowEdit"
|
||||
item-key="name"
|
||||
group="items"
|
||||
@end="updateOutline"
|
||||
:data-chapter="chapter.name"
|
||||
>
|
||||
<template #item="{ element: lesson }">
|
||||
<div
|
||||
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
|
||||
:class="
|
||||
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
|
||||
"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.number.split('.')[0],
|
||||
lessonNumber: lesson.number.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center text-sm leading-5 group">
|
||||
<MonitorPlay
|
||||
v-if="lesson.icon === 'icon-youtube'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<HelpCircle
|
||||
v-else-if="lesson.icon === 'icon-quiz'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<FileText
|
||||
v-else-if="lesson.icon === 'icon-list'"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
||||
/>
|
||||
{{ lesson.title }}
|
||||
<Trash2
|
||||
v-if="allowEdit"
|
||||
@click.prevent="
|
||||
trashLesson(lesson.name, chapter.name)
|
||||
"
|
||||
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
|
||||
/>
|
||||
<Check
|
||||
v-if="lesson.is_complete"
|
||||
class="h-4 w-4 text-green-700 ml-2"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
||||
<router-link
|
||||
v-if="!chapter.is_scorm_package"
|
||||
:to="{
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: chapter.idx,
|
||||
lessonNumber: chapter.lessons.length + 1,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Add Lesson') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</div>
|
||||
<ChapterModal
|
||||
@@ -148,7 +164,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
|
||||
import { getCurrentInstance, inject, ref } from 'vue'
|
||||
import { getCurrentInstance, inject, ref, watch } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||
import {
|
||||
@@ -197,13 +213,22 @@ const props = defineProps({
|
||||
const outline = createResource({
|
||||
url: 'lms.lms.utils.get_course_outline',
|
||||
cache: ['course_outline', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
progress: props.getProgress,
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
progress: props.getProgress,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
outline.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const deleteLesson = createResource({
|
||||
url: 'lms.lms.api.delete_lesson',
|
||||
makeParams(values) {
|
||||
@@ -233,6 +258,20 @@ const updateLessonIndex = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const updateChapterIndex = createResource({
|
||||
url: 'lms.lms.api.update_chapter_index',
|
||||
makeParams(values) {
|
||||
return {
|
||||
chapter: values.chapter,
|
||||
course: values.course,
|
||||
idx: values.idx,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success(__('Chapter moved successfully'))
|
||||
},
|
||||
})
|
||||
|
||||
const trashLesson = (lessonName, chapterName) => {
|
||||
$dialog({
|
||||
title: __('Delete this lesson?'),
|
||||
@@ -278,6 +317,14 @@ const updateOutline = (e) => {
|
||||
})
|
||||
}
|
||||
|
||||
const updateChapterOrder = (e) => {
|
||||
updateChapterIndex.submit({
|
||||
chapter: e.item.__draggable_context.element.name,
|
||||
course: props.courseName,
|
||||
idx: e.newIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const deleteChapter = createResource({
|
||||
url: 'lms.lms.api.delete_chapter',
|
||||
makeParams(values) {
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<script setup>
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { computed, ref, inject } from 'vue'
|
||||
import { watch, ref, inject } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
||||
|
||||
@@ -101,12 +101,21 @@ const hasReviewed = createResource({
|
||||
const reviews = createResource({
|
||||
url: 'lms.lms.utils.get_reviews',
|
||||
cache: ['course_reviews', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
reviews.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const showReviewModal = ref(false)
|
||||
|
||||
function openReviewModal() {
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
"
|
||||
:options="[
|
||||
{
|
||||
label: 'Edit',
|
||||
label: __('Edit'),
|
||||
onClick() {
|
||||
reply.editable = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
label: __('Delete'),
|
||||
onClick() {
|
||||
deleteReply(reply)
|
||||
},
|
||||
@@ -94,7 +94,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
||||
import { timeAgo } from '../utils'
|
||||
import { timeAgo } from '@/utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
class="float-right"
|
||||
@click="openTopicModal()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="size-4" />
|
||||
</template>
|
||||
{{ __('New {0}').format(singularize(title)) }}
|
||||
</Button>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
@@ -49,7 +52,7 @@
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
||||
>
|
||||
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" />
|
||||
<div class="">
|
||||
<div class="mt-2">
|
||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||
{{ __(emptyStateTitle) }}
|
||||
</div>
|
||||
@@ -69,11 +72,11 @@
|
||||
<script setup>
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { singularize, timeAgo } from '../utils'
|
||||
import { singularize, timeAgo } from '@/utils'
|
||||
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||
import { MessageSquareText } from 'lucide-vue-next'
|
||||
import { MessageSquareText, Plus } from 'lucide-vue-next'
|
||||
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||
|
||||
const showTopics = ref(true)
|
||||
@@ -102,7 +105,7 @@ const props = defineProps({
|
||||
},
|
||||
emptyStateText: {
|
||||
type: String,
|
||||
default: 'Start a discussion',
|
||||
default: 'Start a Discussion',
|
||||
},
|
||||
singleThread: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -15,60 +15,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-2" v-for="(item, key) in contentMap" :key="key">
|
||||
<div
|
||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||
@click="openHelpDialog('quiz')"
|
||||
@click="openHelpDialog(key)"
|
||||
>
|
||||
<span>
|
||||
{{ __('How to add a Quiz?') }}
|
||||
{{ __(item.title) }}
|
||||
</span>
|
||||
<Info class="w-3 h-3 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
||||
{{
|
||||
__(
|
||||
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="flex text-sm font-medium space-x-2 cursor-pointer"
|
||||
@click="openHelpDialog('upload')"
|
||||
>
|
||||
<span class="leading-5">
|
||||
{{ __(contentMap['upload']) }}
|
||||
</span>
|
||||
<Info class="w-3 h-3 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
||||
{{
|
||||
__(
|
||||
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||
@click="openHelpDialog('youtube')"
|
||||
>
|
||||
<span>
|
||||
{{ __(contentMap['youtube']) }}
|
||||
</span>
|
||||
<Info class="w-3 h-3 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
||||
{{
|
||||
__(
|
||||
'Copy the URL of the video from YouTube and paste it in the editor.'
|
||||
)
|
||||
}}
|
||||
{{ __(item.description) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,14 +41,31 @@ const showExplanation = ref(false)
|
||||
const type = ref(null)
|
||||
const title = ref(null)
|
||||
const contentMap = {
|
||||
quiz: 'How to add a Quiz?',
|
||||
upload: 'How to upload content from your system?',
|
||||
youtube: 'How to add a YouTube Video?',
|
||||
quiz: {
|
||||
title: 'How to add a Quiz?',
|
||||
description:
|
||||
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.',
|
||||
},
|
||||
upload: {
|
||||
title: 'How to upload content from your system?',
|
||||
description:
|
||||
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.',
|
||||
},
|
||||
youtube: {
|
||||
title: 'How to add a YouTube Video?',
|
||||
description:
|
||||
'Copy the URL of the video from YouTube and paste it in the editor.',
|
||||
},
|
||||
remove: {
|
||||
title: 'How to remove an embed?',
|
||||
description:
|
||||
'To remove an embed like YouTube or Vimeo, put your cursor on the line below the embed, then drag your mouse cursor upwards to select the embed. Once the embed is selected press BackSpace.',
|
||||
},
|
||||
}
|
||||
|
||||
const openHelpDialog = (contentType) => {
|
||||
type.value = contentType
|
||||
title.value = contentMap[contentType]
|
||||
title.value = contentMap[contentType].title
|
||||
showExplanation.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -54,15 +54,17 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { getSidebarLinks } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { watch, ref, onMounted } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import * as icons from 'lucide-vue-next'
|
||||
|
||||
const { logout, user, sidebarSettings } = sessionStore()
|
||||
const { logout, user } = sessionStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
const { sidebarSettings } = useSettings()
|
||||
const router = useRouter()
|
||||
let { userResource } = usersStore()
|
||||
const sidebarLinks = ref(getSidebarLinks())
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Reply To') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<Input type="text" v-model="announcement.replyTo" />
|
||||
</div>
|
||||
@@ -70,8 +71,8 @@ const announcementResource = createResource({
|
||||
url: 'frappe.core.doctype.communication.email.make',
|
||||
makeParams(values) {
|
||||
return {
|
||||
recipients: props.students.join(', '),
|
||||
cc: announcement.replyTo,
|
||||
recipients: announcement.replyTo,
|
||||
bcc: props.students.join(', '),
|
||||
subject: announcement.subject,
|
||||
content: announcement.announcement,
|
||||
doctype: 'LMS Batch',
|
||||
@@ -95,6 +96,9 @@ const makeAnnouncement = (close) => {
|
||||
if (!announcement.announcement) {
|
||||
return __('Announcement is required')
|
||||
}
|
||||
if (!announcement.replyTo) {
|
||||
return __('Reply To is required')
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
close()
|
||||
|
||||
@@ -99,6 +99,7 @@ const assessmentTypes = computed(() => {
|
||||
return [
|
||||
{ label: 'Quiz', value: 'LMS Quiz' },
|
||||
{ label: 'Assignment', value: 'LMS Assignment' },
|
||||
{ label: 'Programming Exercise', value: 'LMS Programming Exercise' },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
230
frontend/src/components/Modals/CourseProgressSummary.vue
Normal file
230
frontend/src/components/Modals/CourseProgressSummary.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Course Progress Summary'),
|
||||
size: '5xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex justify-between space-x-10 text-base mt-10">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||
<!-- <div class="text-xl font-semibold text-ink-gray-6">
|
||||
{{ __('{0} Members').format(memberCount) }}
|
||||
</div> -->
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="max-h-[70vh] overflow-y-auto">
|
||||
<ListView
|
||||
v-if="progressList.loading || progressList.data?.length"
|
||||
:columns="progressColumns"
|
||||
:rows="progressList.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in progressColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
:name="item.icon?.toString()"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in progressList.data">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: row.member_username },
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
v-if="progressList.data && progressList.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
>
|
||||
<Button @click="progressList.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 self-start w-full space-y-5">
|
||||
<div class="flex items-center space-x-4">
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Enrollments'),
|
||||
value: memberCount || 0,
|
||||
}"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Average Progress %'),
|
||||
value: chartDetails.data?.average_progress || 0,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<DonutChart
|
||||
:config="{
|
||||
data: chartDetails.data?.progress_distribution || [],
|
||||
title: __('Progress Distribution'),
|
||||
categoryColumn: 'category',
|
||||
valueColumn: 'count',
|
||||
colors: [
|
||||
theme.colors.red['400'],
|
||||
theme.colors.amber['400'],
|
||||
theme.colors.pink['400'],
|
||||
theme.colors.blue['400'],
|
||||
theme.colors.green['400'],
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
Dialog,
|
||||
DonutChart,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
NumberChart,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const show = defineModel<boolean | undefined>()
|
||||
const searchFilter = ref<string | null>(null)
|
||||
type Filters = {
|
||||
course: string | undefined
|
||||
member_name?: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
courseName?: string
|
||||
enrollments?: number
|
||||
}>()
|
||||
|
||||
const memberCount = ref<number>(props.enrollments || 0)
|
||||
|
||||
const chartDetails = createResource({
|
||||
url: 'lms.lms.api.get_course_progress_distribution',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const progressList = createListResource({
|
||||
doctype: 'LMS Enrollment',
|
||||
filters: {
|
||||
course: props.courseName,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'member_username',
|
||||
'progress',
|
||||
],
|
||||
pageLength: 50,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch([searchFilter], () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
course: props.courseName,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
filterApplied = true
|
||||
}
|
||||
|
||||
progressList.update({
|
||||
filters: filters,
|
||||
})
|
||||
progressList.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess(data: any[]) {
|
||||
memberCount.value = filterApplied ? data.length : props.enrollments || 0
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const progressColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
width: '60%',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
width: '30%',
|
||||
align: 'right',
|
||||
icon: 'trending-up',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -255,6 +255,9 @@ const saveEvaluation = () => {
|
||||
}
|
||||
toast.success(__('Evaluation saved successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -277,6 +280,9 @@ const certificateResource = createResource({
|
||||
onSuccess(data) {
|
||||
certificate.name = data
|
||||
},
|
||||
onError(err) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
})
|
||||
|
||||
const certificateDetails = createResource({
|
||||
@@ -310,6 +316,9 @@ const saveCertificate = () => {
|
||||
onSuccess: () => {
|
||||
toast.success(__('Certificate saved successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,5 +35,6 @@ const file = computed(() => {
|
||||
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
|
||||
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
|
||||
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
|
||||
if (props.type == 'remove') return '/assets/lms/frontend/Remove.mp4'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,44 +3,59 @@
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Attendance for Class - {0}').format(live_class?.title),
|
||||
size: 'xl',
|
||||
size: '4xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-5">
|
||||
<div
|
||||
class="grid grid-cols-2 gap-12 text-sm font-semibold text-ink-gray-5 pb-2"
|
||||
>
|
||||
<div>
|
||||
{{ __('Member') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-20">
|
||||
<div>
|
||||
{{ __('Joined at') }}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{{ __('Left at') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Attended for') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y text-base">
|
||||
<div
|
||||
v-for="participant in participants.data"
|
||||
@click="redirectToProfile(participant.member_username)"
|
||||
class="cursor-pointer text-base w-fit"
|
||||
class="grid grid-cols-2 items-center w-full text-base w-fit py-2"
|
||||
>
|
||||
<Tooltip placement="right">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Avatar
|
||||
:image="participant.member_image"
|
||||
:label="participant.member_name"
|
||||
size="xl"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ participant.member_name }}
|
||||
</div>
|
||||
<div>
|
||||
{{ participant.member }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Avatar
|
||||
:image="participant.member_image"
|
||||
:label="participant.member_name"
|
||||
size="xl"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ participant.member_name }}
|
||||
</div>
|
||||
<div>
|
||||
{{ participant.member }}
|
||||
</div>
|
||||
</div>
|
||||
<template #body>
|
||||
<div
|
||||
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-p-xs text-ink-white leading-5 shadow-xl"
|
||||
>
|
||||
{{ dayjs(participant.joined_at).format('HH:mm a') }} -
|
||||
{{ dayjs(participant.left_at).format('HH:mm a') }}
|
||||
<br />
|
||||
{{ __('attended for') }} {{ participant.duration }}
|
||||
{{ __('minutes') }}
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-20 text-right">
|
||||
<div>
|
||||
{{ dayjs(participant.joined_at).format('HH:mm a') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ dayjs(participant.left_at).format('HH:mm a') }}
|
||||
</div>
|
||||
<div>{{ participant.duration }} {{ __('minutes') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '3xl',
|
||||
size: '5xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
@@ -21,7 +21,7 @@
|
||||
class="!p-0"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!chooseFromExisting || editMode" class="space-y-2">
|
||||
<div v-if="!chooseFromExisting || editMode">
|
||||
<div>
|
||||
<label class="block text-xs text-ink-gray-5 mb-1">
|
||||
{{ __('Question') }}
|
||||
@@ -34,7 +34,7 @@
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-2 gap-8 mt-4">
|
||||
<FormControl
|
||||
v-model="question.marks"
|
||||
:label="__('Marks')"
|
||||
@@ -51,7 +51,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="question.type == 'Choices'"
|
||||
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
|
||||
class="text-base font-semibold text-ink-gray-9 mb-5 mt-10"
|
||||
>
|
||||
{{ __('Options') }}
|
||||
</div>
|
||||
@@ -61,7 +61,10 @@
|
||||
>
|
||||
{{ __('Possibilities') }}
|
||||
</div>
|
||||
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
v-if="question.type == 'Choices'"
|
||||
class="grid grid-cols-2 gap-x-8 gap-y-4"
|
||||
>
|
||||
<div v-for="n in 4" class="space-y-4 py-2">
|
||||
<FormControl
|
||||
:label="__('Option') + ' ' + n"
|
||||
@@ -81,7 +84,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="question.type == 'User Input'"
|
||||
class="grid grid-cols-2 gap-4 py-2"
|
||||
class="grid grid-cols-2 gap-x-8 gap-y-4 py-2"
|
||||
>
|
||||
<div v-for="n in 4">
|
||||
<FormControl
|
||||
@@ -106,7 +109,7 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-end space-x-2 mt-5">
|
||||
<Button variant="solid" @click="submitQuestion()">
|
||||
{{ __('Submit') }}
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +220,7 @@ const questionRow = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Quiz Question',
|
||||
parent: quiz.value.data.name,
|
||||
parent: quiz.value.doc.name,
|
||||
parentfield: 'questions',
|
||||
parenttype: 'LMS Quiz',
|
||||
...values,
|
||||
|
||||
252
frontend/src/components/Modals/VideoStatistics.vue
Normal file
252
frontend/src/components/Modals/VideoStatistics.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '4xl',
|
||||
title: __('Video Statistics for {0}').format(lessonTitle),
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center justify-between">
|
||||
<TabButtons
|
||||
v-if="tabs.length > 1"
|
||||
:buttons="tabs"
|
||||
v-model="currentTab"
|
||||
class="w-fit"
|
||||
/>
|
||||
<!-- <FormControl
|
||||
v-model="searchText"
|
||||
:placeholder="__('Search by Member')"
|
||||
class="mt-2 mr-5 w-[25%]"
|
||||
/> -->
|
||||
</div>
|
||||
<div v-if="currentTab" class="mt-4">
|
||||
<div class="grid grid-cols-[55%,40%] gap-5">
|
||||
<div
|
||||
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto"
|
||||
>
|
||||
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
|
||||
<div class="px-4">
|
||||
{{ __('Member') }}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{{ __('Watch Time') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in currentTabData"
|
||||
class="hover:bg-surface-gray-1 cursor-pointer rounded-md py-1 px-2"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: row.member_username },
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[70%,30%] items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Avatar
|
||||
:image="row.member_image"
|
||||
:label="row.member_name"
|
||||
size="xl"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ row.member_name }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-6">
|
||||
{{ row.member }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-sm">
|
||||
{{ formatTimestamp(row.watch_time) }}
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Average Watch Time'),
|
||||
value: averageWatchTime,
|
||||
}"
|
||||
/>
|
||||
<div v-if="isPlyrSource">
|
||||
<div class="video-player" :src="currentTab"></div>
|
||||
</div>
|
||||
<VideoBlock v-else :file="currentTab" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-ink-gray-5">
|
||||
{{ __('No statistics available for this video.') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
createListResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
NumberChart,
|
||||
TabButtons,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { enablePlyr, formatTimestamp } from '@/utils'
|
||||
import VideoBlock from '@/components/VideoBlock.vue'
|
||||
|
||||
const show = defineModel<boolean | undefined>()
|
||||
const currentTab = ref<string>('')
|
||||
const searchText = ref<string>('')
|
||||
type Filters = {
|
||||
lesson: string | undefined
|
||||
member_name?: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
lessonName?: string
|
||||
lessonTitle?: string
|
||||
}>()
|
||||
|
||||
const statistics = createListResource({
|
||||
doctype: 'LMS Video Watch Duration',
|
||||
filters: {
|
||||
lesson: props.lessonName,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'member_username',
|
||||
'source',
|
||||
'watch_time',
|
||||
],
|
||||
cache: ['videoStatistics', props.lessonName],
|
||||
onSuccess() {
|
||||
currentTab.value = Object.keys(statisticsData.value)[0]
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.lessonName,
|
||||
() => {
|
||||
if (props.lessonName) {
|
||||
statistics.filters.lesson = props.lessonName
|
||||
statistics.reload()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(searchText, () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
lesson: props.lessonName,
|
||||
}
|
||||
|
||||
if (searchText.value) {
|
||||
filters.member_name = ['like', `%${searchText.value}%`]
|
||||
filterApplied = true
|
||||
}
|
||||
|
||||
statistics.update({
|
||||
filters: filters,
|
||||
})
|
||||
|
||||
statistics.reload({})
|
||||
})
|
||||
|
||||
watch(show, () => {
|
||||
if (show.value) {
|
||||
enablePlyr()
|
||||
}
|
||||
})
|
||||
|
||||
const statisticsData = computed(() => {
|
||||
const grouped = <Record<string, any[]>>{}
|
||||
statistics.data.forEach((item: { source: string }) => {
|
||||
if (!grouped[item.source]) {
|
||||
grouped[item.source] = []
|
||||
}
|
||||
grouped[item.source].push(item)
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
|
||||
const averageWatchTime = computed(() => {
|
||||
let totalWatchTime = 0
|
||||
|
||||
currentTabData.value.forEach((item: { watch_time: string }) => {
|
||||
totalWatchTime += parseFloat(item.watch_time)
|
||||
})
|
||||
|
||||
return formatTimestamp(totalWatchTime / currentTabData.value.length)
|
||||
})
|
||||
|
||||
const currentTabData = computed(() => {
|
||||
return statisticsData.value[currentTab.value] || []
|
||||
})
|
||||
|
||||
const isPlyrSource = computed(() => {
|
||||
return (
|
||||
currentTab.value.includes('youtube') || currentTab.value.includes('vimeo')
|
||||
)
|
||||
})
|
||||
|
||||
const provider = computed(() => {
|
||||
if (currentTab.value.includes('youtube')) {
|
||||
return 'youtube'
|
||||
} else if (currentTab.value.includes('vimeo')) {
|
||||
return 'vimeo'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const embedURL = computed(() => {
|
||||
if (isPlyrSource.value) {
|
||||
return currentTab.value.replace('watch?v=', 'embed/')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
return Object.keys(statisticsData.value).map((source, index) => ({
|
||||
label: __(`Video ${index + 1}`),
|
||||
value: source,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.plyr__volume input[type='range'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(0, 0, 0, 0.4) 0%,
|
||||
rgba(0, 0, 0, 0.5) 50%
|
||||
);
|
||||
}
|
||||
|
||||
.plyr__control:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.plyr--video {
|
||||
border: 1px solid theme('colors.gray.200');
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--plyr-range-fill-background: white;
|
||||
--plyr-video-control-background-hover: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -41,7 +41,7 @@
|
||||
v-model="account.member"
|
||||
:label="__('Member')"
|
||||
doctype="Course Evaluator"
|
||||
:onCreate="(value, close) => openSettings('Members', close)"
|
||||
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
@@ -86,6 +86,12 @@ interface ZoomAccounts {
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
setValue: {
|
||||
submit: (
|
||||
data: ZoomAccount,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
}
|
||||
|
||||
const show = defineModel('show')
|
||||
@@ -137,7 +143,7 @@ watch(show, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
const saveAccount = (close) => {
|
||||
const saveAccount = (close: () => void) => {
|
||||
if (props.accountID == 'new') {
|
||||
createAccount(close)
|
||||
} else {
|
||||
@@ -145,7 +151,7 @@ const saveAccount = (close) => {
|
||||
}
|
||||
}
|
||||
|
||||
const createAccount = (close) => {
|
||||
const createAccount = (close: () => void) => {
|
||||
zoomAccounts.value?.insert.submit(
|
||||
{
|
||||
account_name: account.name,
|
||||
@@ -167,7 +173,7 @@ const createAccount = (close) => {
|
||||
)
|
||||
}
|
||||
|
||||
const updateAccount = async (close) => {
|
||||
const updateAccount = async (close: () => void) => {
|
||||
if (props.accountID != account.name) {
|
||||
await renameDoc()
|
||||
}
|
||||
@@ -182,11 +188,12 @@ const renameDoc = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const setValue = (close) => {
|
||||
const setValue = (close: () => void) => {
|
||||
zoomAccounts.value?.setValue.submit(
|
||||
{
|
||||
...account,
|
||||
name: account.name,
|
||||
account_name: props.accountID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
@@ -194,7 +201,7 @@ const setValue = (close) => {
|
||||
close()
|
||||
toast.success(__('Zoom Account updated successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
onError(err: any) {
|
||||
close()
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) || __('Error updating Zoom Account')
|
||||
|
||||
241
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
241
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div
|
||||
class="text-sm absolute bg-white border rounded-md z-10 w-44"
|
||||
:style="{
|
||||
display: top > 0 ? 'block' : 'none',
|
||||
top: top + 'px',
|
||||
left: left + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="space-y-2 py-2">
|
||||
<div class="text-xs text-ink-gray-5 font-medium px-3">
|
||||
{{ __('Highlight') }}
|
||||
</div>
|
||||
<div class="">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
class="flex items-center space-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
|
||||
@click="saveHighLight(color)"
|
||||
>
|
||||
<span
|
||||
class="size-3 rounded-full"
|
||||
:style="{
|
||||
backgroundColor: theme.backgroundColor[color.toLowerCase()][400],
|
||||
}"
|
||||
></span>
|
||||
<span>
|
||||
{{ __(color) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t">
|
||||
<div
|
||||
@click="addToNotes()"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<NotepadText class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Add to Notes') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="highlightExists()"
|
||||
@click="deleteHighlight"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<Trash2 class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Remove Highlight') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { NotepadText, Trash2 } from 'lucide-vue-next'
|
||||
import { theme } from '@/utils/theme'
|
||||
import type { Note, Notes } from '@/components/Notes/types'
|
||||
import { blockQuotesClick, highlightText } from '@/utils'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const show = defineModel()
|
||||
const notes = defineModel<Notes>('notes')
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const currentSelection = ref<Selection | null>(null)
|
||||
const selectedText = ref('')
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateNotes'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
lesson: string
|
||||
}>()
|
||||
|
||||
watch(show, () => {
|
||||
if (!show.value) {
|
||||
return resetMenuPosition()
|
||||
}
|
||||
|
||||
currentSelection.value = window.getSelection()
|
||||
if (!currentSelection.value?.toString()) {
|
||||
return resetMenuPosition()
|
||||
}
|
||||
|
||||
updateMenuPosition()
|
||||
})
|
||||
|
||||
const updateMenuPosition = () => {
|
||||
selectedText.value = currentSelection.value?.toString() || ''
|
||||
const range = currentSelection.value?.getRangeAt(0)
|
||||
const rect = range?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
const offsetY = window.scrollY
|
||||
const offsetX = window.scrollX
|
||||
|
||||
top.value = Math.floor(rect.top + offsetY - 40)
|
||||
left.value = Math.floor(rect.right + offsetX + 10)
|
||||
}
|
||||
|
||||
const resetMenuPosition = () => {
|
||||
top.value = 0
|
||||
left.value = 0
|
||||
}
|
||||
|
||||
const colors = computed(() => {
|
||||
return ['Red', 'Blue', 'Green', 'Yellow', 'Purple']
|
||||
})
|
||||
|
||||
const highlightExists = () => {
|
||||
return notes.value?.data?.some(
|
||||
(note: Note) => note.highlighted_text === selectedText.value
|
||||
)
|
||||
}
|
||||
|
||||
const saveHighLight = (color: string) => {
|
||||
if (!selectedText.value) return
|
||||
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
highlighted_text: selectedText.value,
|
||||
color: color,
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
highlightText(data)
|
||||
resetStates()
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error saving highlight:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteHighlight = () => {
|
||||
let notesToDelete = notes.value?.data.find(
|
||||
(note: Note) => note.highlighted_text === selectedText.value
|
||||
)
|
||||
if (!notesToDelete) return
|
||||
notes.value?.delete.submit(notesToDelete.name, {
|
||||
onSuccess() {
|
||||
resetStates()
|
||||
document.querySelectorAll('.highlighted-text').forEach((el) => {
|
||||
const element = el as HTMLElement
|
||||
if (element.dataset.name === notesToDelete.name) {
|
||||
element.style.backgroundColor = 'transparent'
|
||||
}
|
||||
})
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error deleting highlight:', err)
|
||||
resetStates()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const addToNotes = () => {
|
||||
if (!selectedText.value) return
|
||||
let noteToUpdate = notes.value?.data.find((note: Note) => {
|
||||
return !note.highlighted_text && note.note !== ''
|
||||
})
|
||||
if (!noteToUpdate) {
|
||||
createNote()
|
||||
} else {
|
||||
updateNote(noteToUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
const createNote = () => {
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: `<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||
color: 'Yellow',
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
emit('updateNotes')
|
||||
setTimeout(() => {
|
||||
scrollToText(selectedText.value)
|
||||
blockQuotesClick()
|
||||
resetStates()
|
||||
}, 100)
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error creating note:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateNote = (noteToUpdate: Note) => {
|
||||
notes.value?.setValue.submit(
|
||||
{
|
||||
name: noteToUpdate.name,
|
||||
note: `${noteToUpdate.note}\n\n<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
emit('updateNotes')
|
||||
setTimeout(() => {
|
||||
scrollToText(selectedText.value)
|
||||
blockQuotesClick()
|
||||
resetStates()
|
||||
}, 100)
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error updating note:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const scrollToText = (text: string) => {
|
||||
const elements = document.querySelectorAll('blockquote p')
|
||||
Array.from(elements).forEach((el) => {
|
||||
const element = el as HTMLElement
|
||||
if (element.textContent?.toLowerCase().includes(text.toLowerCase())) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resetStates = () => {
|
||||
selectedText.value = ''
|
||||
show.value = false
|
||||
resetMenuPosition()
|
||||
}
|
||||
</script>
|
||||
115
frontend/src/components/Notes/Notes.vue
Normal file
115
frontend/src/components/Notes/Notes.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('My Notes') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="note"
|
||||
:placeholder="__('Make notes for quick revision. Press / for menu.')"
|
||||
@change="(val: string) => updateNoteText(val)"
|
||||
:editable="true"
|
||||
editorClass="prose prose-sm min-h-[200px] max-w-none"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { TextEditor } from 'frappe-ui'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { inject, ref, onMounted, watch } from 'vue'
|
||||
import type { Note, Notes } from '@/components/Notes/types'
|
||||
import { blockQuotesClick } from '@/utils/'
|
||||
|
||||
const note = ref<string | null>(null)
|
||||
const currentNoteName = ref<string | null>(null)
|
||||
const user = inject<any>('$user')
|
||||
const notes = defineModel<Notes>('notes')
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateNotes'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
lesson: string
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
updateCurrentNote()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => notes.value?.data,
|
||||
() => {
|
||||
updateCurrentNote()
|
||||
blockQuotesClick()
|
||||
}
|
||||
)
|
||||
|
||||
const updateCurrentNote = () => {
|
||||
const currentNote = notes.value?.data?.filter((row: Note) => {
|
||||
return !row.highlighted_text && row.note !== ''
|
||||
})
|
||||
if (currentNote?.length === 0) {
|
||||
note.value = null
|
||||
currentNoteName.value = null
|
||||
return
|
||||
} else if (currentNote && currentNote.length > 0) {
|
||||
currentNoteName.value = currentNote[0].name
|
||||
note.value = currentNote[0].note || null
|
||||
}
|
||||
}
|
||||
|
||||
const updateNoteText = (val: string) => {
|
||||
note.value = val
|
||||
debouncedSave()
|
||||
}
|
||||
|
||||
const debouncedSave = useDebounceFn(() => {
|
||||
saveNotes()
|
||||
}, 2000)
|
||||
|
||||
const saveNotes = () => {
|
||||
if (currentNoteName.value) {
|
||||
updateNote()
|
||||
} else {
|
||||
createNote()
|
||||
}
|
||||
}
|
||||
|
||||
const createNote = () => {
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: note.value,
|
||||
color: 'Yellow',
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
currentNoteName.value = data.name || null
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error creating note:', err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateNote = () => {
|
||||
if (!currentNoteName.value) return
|
||||
notes.value?.setValue.submit(
|
||||
{
|
||||
name: currentNoteName.value,
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: note.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error updating note:', err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
32
frontend/src/components/Notes/types.ts
Normal file
32
frontend/src/components/Notes/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
export type Note = {
|
||||
highlighted_text?: string
|
||||
color?: string
|
||||
name: string
|
||||
note?: string | null
|
||||
lesson?: string
|
||||
member?: string
|
||||
}
|
||||
|
||||
export type Notes = {
|
||||
data: Note[]
|
||||
reload: () => void
|
||||
insert: {
|
||||
submit: (
|
||||
data: Note,
|
||||
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
setValue: {
|
||||
submit: (
|
||||
data: Note,
|
||||
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||
) => void
|
||||
},
|
||||
delete: {
|
||||
submit: (
|
||||
data: Note | string,
|
||||
options?: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="quiz.data">
|
||||
<div
|
||||
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
||||
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
|
||||
>
|
||||
<div v-if="inVideo">
|
||||
{{ __('You will have to complete the quiz to continue the video') }}
|
||||
@@ -41,6 +41,16 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
|
||||
).format(
|
||||
quiz.data.marks_to_cut,
|
||||
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||
@@ -638,6 +648,8 @@ const getInstructions = (question) => {
|
||||
|
||||
const markLessonProgress = () => {
|
||||
let pathname = window.location.pathname.split('/')
|
||||
if (!pathname.includes('courses'))
|
||||
pathname = window.parent.location.pathname.split('/')
|
||||
if (pathname[2] != 'courses') return
|
||||
let lessonIndex = pathname.pop().split('-')
|
||||
|
||||
|
||||
52
frontend/src/components/RelatedCourses.vue
Normal file
52
frontend/src/components/RelatedCourses.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div v-if="relatedCourses.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||
{{ __('Related Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
|
||||
>
|
||||
<router-link
|
||||
v-for="course in relatedCourses.data"
|
||||
:key="course.name"
|
||||
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { watch } from 'vue'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const relatedCourses = createResource({
|
||||
url: 'lms.lms.utils.get_related_courses',
|
||||
cache: ['related_courses', props.courseName],
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
relatedCourses.reload()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
142
frontend/src/components/Settings/BadgeAssignmentForm.vue
Normal file
142
frontend/src/components/Settings/BadgeAssignmentForm.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title:
|
||||
props.badgeAssignmentID === 'new'
|
||||
? __('Assign a Badge')
|
||||
: __('Edit Badge Assignment'),
|
||||
size: 'sm',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: ({ close }) => {
|
||||
saveBadgeAssignment(close)
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<Link
|
||||
doctype="User"
|
||||
v-model="badgeAssignment.member"
|
||||
:label="__('Member')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Badge"
|
||||
v-model="badgeAssignment.badge"
|
||||
:label="__('Badge')"
|
||||
:required="true"
|
||||
/>
|
||||
<div>
|
||||
<label class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __('Issued On') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<DatePicker
|
||||
v-model="badgeAssignment.issued_on"
|
||||
:placeholder="__('Select Date')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog, DatePicker, toast } from 'frappe-ui'
|
||||
import type {
|
||||
BadgeAssignments,
|
||||
BadgeAssignment,
|
||||
} from '@/components/Settings/types'
|
||||
import { ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const defaultBadgeAssignment = {
|
||||
name: '',
|
||||
badge: '',
|
||||
member: '',
|
||||
issued_on: '',
|
||||
member_name: '',
|
||||
member_username: '',
|
||||
member_image: '',
|
||||
}
|
||||
const badgeAssignments = defineModel<BadgeAssignments>('badgeAssignments')
|
||||
const badgeAssignment = ref<BadgeAssignment>(defaultBadgeAssignment)
|
||||
|
||||
const props = defineProps<{
|
||||
badgeAssignmentID: string
|
||||
badge: string | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.badgeAssignmentID,
|
||||
(newID) => {
|
||||
if (newID === 'new') {
|
||||
badgeAssignment.value = {
|
||||
...defaultBadgeAssignment,
|
||||
badge: props.badge || '',
|
||||
}
|
||||
} else {
|
||||
const assignment = badgeAssignments.value?.data?.find(
|
||||
(assignment) => assignment.name === newID
|
||||
)
|
||||
if (assignment) {
|
||||
badgeAssignment.value = { ...assignment }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const saveBadgeAssignment = (close: () => void) => {
|
||||
if (props.badgeAssignmentID === 'new') {
|
||||
createBadgeAssignment(close)
|
||||
} else {
|
||||
updateBadgeAssignment(close)
|
||||
}
|
||||
}
|
||||
|
||||
const updateBadgeAssignment = async (close: () => void) => {
|
||||
badgeAssignments.value?.setValue.submit(
|
||||
{
|
||||
...badgeAssignment.value,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(__('Badge assignment updated successfully'))
|
||||
close()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
__('Failed to update badge assignment: ') + cleanError(error)
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const createBadgeAssignment = (close: () => void) => {
|
||||
badgeAssignments.value?.insert.submit(
|
||||
{
|
||||
...badgeAssignment.value,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(__('Badge assignment created successfully'))
|
||||
close()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
__('Failed to create badge assignment: ') + cleanError(error)
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
192
frontend/src/components/Settings/BadgeAssignments.vue
Normal file
192
frontend/src/components/Settings/BadgeAssignments.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center justify-between space-x-2 mb-5">
|
||||
<div class="flex items-center space-x-2">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-5 cursor-pointer"
|
||||
@click="
|
||||
() => {
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ props.badgeName }}
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="assignments.data?.length">
|
||||
<ListView
|
||||
:rows="assignments.data"
|
||||
:columns="columns"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
onRowClick: (row: BadgeAssignment) => {
|
||||
openForm(row.name)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
class="h-4 w-4 stroke-1.5"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in assignments.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="leading-5 text-sm">
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteBadgeAssignment(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center justify-center mt-44">
|
||||
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
|
||||
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
|
||||
{{ __('No Assignments') }}
|
||||
</div>
|
||||
<div
|
||||
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
|
||||
>
|
||||
{{ __('This badge has not been assigned to any students yet') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BadgeAssignmentForm
|
||||
v-model="showForm"
|
||||
:badgeAssignmentID="currentAssignmentID"
|
||||
:badge="props.badgeName"
|
||||
v-model:badgeAssignments="assignments"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ChevronLeft, GraduationCap, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import type { BadgeAssignment } from '@/components/Settings/types'
|
||||
import BadgeAssignmentForm from '@/components/Settings/BadgeAssignmentForm.vue'
|
||||
|
||||
const show = defineModel<boolean>()
|
||||
const dayjs = inject('$dayjs') as any
|
||||
const showForm = ref(false)
|
||||
const currentAssignmentID = ref<string>('')
|
||||
|
||||
const props = defineProps<{
|
||||
badgeName: string | null
|
||||
}>()
|
||||
|
||||
const assignments = createListResource({
|
||||
doctype: 'LMS Badge Assignment',
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_username',
|
||||
'member_image',
|
||||
'issued_on',
|
||||
'badge',
|
||||
],
|
||||
filters: {
|
||||
badge: props.badgeName,
|
||||
},
|
||||
order_by: 'issued_on desc',
|
||||
transform(data: BadgeAssignment[]) {
|
||||
return data.map((item: BadgeAssignment) => {
|
||||
return {
|
||||
...item,
|
||||
issued_on: item.issued_on
|
||||
? dayjs(item.issued_on).format('DD MMM YYYY')
|
||||
: null,
|
||||
}
|
||||
})
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openForm = (assignmentID: string) => {
|
||||
currentAssignmentID.value = assignmentID
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const deleteBadgeAssignment = (
|
||||
selections: Set<string>,
|
||||
unselectAll: () => void
|
||||
) => {
|
||||
Array.from(selections).forEach(async (assignment: string) => {
|
||||
await assignments.delete.submit(assignment)
|
||||
})
|
||||
unselectAll()
|
||||
toast.success(__('Badge assignments deleted successfully'))
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
icon: 'user',
|
||||
width: '60%',
|
||||
},
|
||||
{
|
||||
label: __('Issued On'),
|
||||
key: 'issued_on',
|
||||
icon: 'calendar',
|
||||
align: 'center',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
219
frontend/src/components/Settings/BadgeForm.vue
Normal file
219
frontend/src/components/Settings/BadgeForm.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: badge ? __('Edit Badge') : __('Create a new Badge'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-x-5">
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="badge.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.title"
|
||||
:label="__('Title')"
|
||||
type="text"
|
||||
:required="true"
|
||||
/>
|
||||
<Autocomplete
|
||||
@update:modelValue="(opt: any) => (badge.reference_doctype = opt.value)"
|
||||
:modelValue="badge.reference_doctype"
|
||||
:options="referenceDoctypeOptions"
|
||||
:required="true"
|
||||
:label="__('Assign For')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.description"
|
||||
:label="__('Description')"
|
||||
:required="true"
|
||||
type="textarea"
|
||||
/>
|
||||
<Uploader
|
||||
v-model="badge.image"
|
||||
label="Badge Image"
|
||||
description="An image that represents the badge."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="badge.grant_only_once"
|
||||
:label="__('Grant Only Once')"
|
||||
type="checkbox"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.event"
|
||||
:label="__('Event')"
|
||||
type="select"
|
||||
:options="eventOptions"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.user_field"
|
||||
:label="__('Assign To')"
|
||||
type="select"
|
||||
:options="userFieldOptions"
|
||||
:required="true"
|
||||
/>
|
||||
<CodeEditor
|
||||
v-model="badge.condition"
|
||||
:label="__('Condition')"
|
||||
type="JavaScript"
|
||||
:required="true"
|
||||
:showBorder="true"
|
||||
height="82px"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<Button variant="solid" @click="saveBadge(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
import type { Badges, Badge } from '@/components/Settings/types'
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
import CodeEditor from '@/components/Controls/CodeEditor.vue'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
|
||||
const defaultBadge = {
|
||||
name: '',
|
||||
title: '',
|
||||
enabled: true,
|
||||
description: '',
|
||||
image: '',
|
||||
grant_only_once: false,
|
||||
event: 'New',
|
||||
reference_doctype: '',
|
||||
condition: '',
|
||||
user_field: 'member',
|
||||
field_to_check: '',
|
||||
}
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const badges = defineModel<Badges>('badges')
|
||||
const badge = ref<Badge>(defaultBadge)
|
||||
|
||||
const props = defineProps<{
|
||||
badgeName: string | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.badgeName,
|
||||
(val) => {
|
||||
if (val != 'new') {
|
||||
badges.value?.data.forEach((bdg: Badge) => {
|
||||
if (bdg.name === val) {
|
||||
badge.value = bdg
|
||||
}
|
||||
})
|
||||
} else {
|
||||
badge.value = { ...defaultBadge }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const saveBadge = (close: () => void) => {
|
||||
if (props.badgeName == 'new') {
|
||||
createBadge(close)
|
||||
} else {
|
||||
updateBadge(close)
|
||||
}
|
||||
}
|
||||
|
||||
const updateBadge = async (close: () => void) => {
|
||||
if (props.badgeName != badge.value?.title) {
|
||||
await renameDoc()
|
||||
}
|
||||
setValue(close)
|
||||
}
|
||||
|
||||
const renameDoc = async () => {
|
||||
await call('frappe.client.rename_doc', {
|
||||
doctype: 'LMS Badge',
|
||||
old_name: props.badgeName,
|
||||
new_name: badge.value?.title,
|
||||
})
|
||||
}
|
||||
|
||||
const setValue = (close: () => void) => {
|
||||
badges.value?.setValue.submit(
|
||||
{
|
||||
...badge.value,
|
||||
name: badge.value.title,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
badges.value?.reload()
|
||||
close()
|
||||
toast.success(__('Badge updated successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
close()
|
||||
toast.error(cleanError(err.messages[0]) || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const createBadge = (close: () => void) => {
|
||||
badges.value?.insert.submit(
|
||||
{
|
||||
...badge.value,
|
||||
name: badge.value.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
badges.value?.reload()
|
||||
close()
|
||||
toast.success(__('Badge created successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
close()
|
||||
toast.error(cleanError(err.messages[0]) || __('Error creating badge'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const referenceDoctypeOptions = computed(() => {
|
||||
return [
|
||||
{ label: __('Course'), value: 'LMS Course' },
|
||||
{ label: __('Batch'), value: 'LMS Batch' },
|
||||
{ label: __('User'), value: 'Member' },
|
||||
{ label: __('Quiz Submission'), value: 'LMS Quiz Submission' },
|
||||
{ label: __('Assignment Submission'), value: 'LMS Assignment Submission' },
|
||||
{
|
||||
label: __('Programming Exercise Submission'),
|
||||
value: 'LMS Programming Exercise Submission',
|
||||
},
|
||||
{ label: __('Course Enrollment'), value: 'LMS Enrollment' },
|
||||
{ label: __('Batch Enrollment'), value: 'LMS Batch Enrollment' },
|
||||
]
|
||||
})
|
||||
|
||||
const eventOptions = computed(() => {
|
||||
let options = ['New', 'Value Change', 'Auto Assign']
|
||||
return options.map((event) => ({ label: __(event), value: event }))
|
||||
})
|
||||
|
||||
const userFieldOptions = computed(() => {
|
||||
return [
|
||||
{ label: __('Member'), value: 'member' },
|
||||
{ label: __('Owner'), value: 'owner' },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
247
frontend/src/components/Settings/Badges.vue
Normal file
247
frontend/src/components/Settings/Badges.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<BadgeAssignments
|
||||
v-if="showAssignments"
|
||||
v-model="showAssignments"
|
||||
:badgeName="showAssignmentsFor"
|
||||
/>
|
||||
<div v-else class="flex flex-col min-h-0 text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="badges.data?.length" class="overflow-y-scroll">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="badges.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns" :key="item.key">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
class="h-4 w-4 stroke-1.5"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in badges.data" :key="row.name">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'enabled'">
|
||||
<Badge v-if="row[column.key]" theme="green">
|
||||
{{ __('Enabled') }}
|
||||
</Badge>
|
||||
<Badge v-else theme="gray">
|
||||
{{ __('Disabled') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else-if="column.key == 'reference_doctype'">
|
||||
{{
|
||||
doctypeLabel[
|
||||
row[column.key] as keyof typeof doctypeLabel
|
||||
] || row[column.key]
|
||||
}}
|
||||
</div>
|
||||
<FormControl
|
||||
v-else-if="column.key == 'grant_only_once'"
|
||||
v-model="row[column.key]"
|
||||
type="checkbox"
|
||||
:disabled="true"
|
||||
/>
|
||||
<div
|
||||
v-else-if="column.key != 'action'"
|
||||
class="leading-5 text-sm"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
<Dropdown
|
||||
v-else
|
||||
:options="getMoreOptions(row.name)"
|
||||
:button="{
|
||||
icon: 'more-horizontal',
|
||||
onblur: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
},
|
||||
}"
|
||||
placement="right"
|
||||
/>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<BadgeForm
|
||||
v-model="showForm"
|
||||
:badgeName="selectedBadge"
|
||||
v-model:badges="badges"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createListResource,
|
||||
Dropdown,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { cleanError } from '@/utils'
|
||||
import BadgeForm from '@/components/Settings/BadgeForm.vue'
|
||||
import BadgeAssignments from '@/components/Settings/BadgeAssignments.vue'
|
||||
|
||||
const showForm = ref<boolean>(false)
|
||||
const selectedBadge = ref<string | null>(null)
|
||||
const showAssignments = ref<boolean>(false)
|
||||
const showAssignmentsFor = ref<string | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const badges = createListResource({
|
||||
doctype: 'LMS Badge',
|
||||
fields: [
|
||||
'name',
|
||||
'title',
|
||||
'enabled',
|
||||
'description',
|
||||
'image',
|
||||
'grant_only_once',
|
||||
'event',
|
||||
'reference_doctype',
|
||||
'condition',
|
||||
'user_field',
|
||||
'field_to_check',
|
||||
],
|
||||
order_by: 'creation desc',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getMoreOptions = (badgeName: string) => {
|
||||
return [
|
||||
{
|
||||
label: __('Edit'),
|
||||
icon: 'edit',
|
||||
onClick() {
|
||||
openForm(badgeName)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Assignments'),
|
||||
icon: 'download',
|
||||
onClick() {
|
||||
showAssignmentsFor.value = badgeName
|
||||
showAssignments.value = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Delete'),
|
||||
icon: 'trash-2',
|
||||
onClick() {
|
||||
deleteBadge(badgeName)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const openForm = (badgeName: string) => {
|
||||
selectedBadge.value = badgeName
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const deleteBadge = (badgeName: string) => {
|
||||
badges.delete
|
||||
.submit(badgeName)
|
||||
.then(() => {
|
||||
badges.reload()
|
||||
toast.success(__('Badge deleted successfully'))
|
||||
})
|
||||
.catch((err: any) => {
|
||||
toast.error(cleanError(err.messages[0]) || __('Error deleting badge'))
|
||||
})
|
||||
}
|
||||
|
||||
const doctypeLabel = computed(() => {
|
||||
return {
|
||||
'LMS Course': __('Course'),
|
||||
'LMS Batch': __('Batch'),
|
||||
'LMS Enrollment': __('Course Enrollment'),
|
||||
'LMS Batch Enrollment': __('Batch Enrollment'),
|
||||
'LMS Quiz Submission': __('Quiz Submission'),
|
||||
'LMS Assignment Submission': __('Assignment Submission'),
|
||||
'LMS Programming Exercise Submission': __(
|
||||
'Programming Exercise Submission'
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Badge'),
|
||||
key: 'title',
|
||||
icon: 'award',
|
||||
align: 'left',
|
||||
width: '25%',
|
||||
},
|
||||
{
|
||||
label: __('Assigned For'),
|
||||
key: 'reference_doctype',
|
||||
icon: 'info',
|
||||
align: 'left',
|
||||
width: '35%',
|
||||
},
|
||||
{
|
||||
label: __('Status'),
|
||||
key: 'enabled',
|
||||
icon: 'check-square',
|
||||
align: 'left',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
label: __('Grant Only Once'),
|
||||
key: 'grant_only_once',
|
||||
icon: 'check',
|
||||
align: 'center',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :fields="fields" :data="data.data" />
|
||||
<SettingFields :fields="fields" :data="branding.data" />
|
||||
</div>
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
@@ -38,10 +38,6 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -51,6 +47,12 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const branding = createResource({
|
||||
url: 'lms.lms.api.get_branding',
|
||||
auto: true,
|
||||
cache: 'brand',
|
||||
})
|
||||
|
||||
const saveSettings = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
@@ -64,7 +66,7 @@ const saveSettings = createResource({
|
||||
|
||||
const update = () => {
|
||||
let fieldsToSave = {}
|
||||
let imageFields = ['favicon', 'banner_image', 'footer_logo']
|
||||
let imageFields = ['favicon', 'banner_image']
|
||||
props.fields.forEach((f) => {
|
||||
if (imageFields.includes(f.name)) {
|
||||
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||
@@ -72,6 +74,8 @@ const update = () => {
|
||||
fieldsToSave[f.name] = f.value
|
||||
}
|
||||
})
|
||||
|
||||
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
|
||||
saveSettings.submit(
|
||||
{
|
||||
fields: fieldsToSave,
|
||||
@@ -84,9 +88,31 @@ const update = () => {
|
||||
)
|
||||
}
|
||||
|
||||
watch(props.data, (newData) => {
|
||||
if (newData && !isDirty.value) {
|
||||
isDirty.value = true
|
||||
}
|
||||
watch(branding, (updatedDoc) => {
|
||||
let textFields = []
|
||||
let imageFields = []
|
||||
|
||||
props.fields.forEach((f) => {
|
||||
if (f.type === 'Upload') {
|
||||
imageFields.push(f.name)
|
||||
} else {
|
||||
textFields.push(f.name)
|
||||
}
|
||||
})
|
||||
|
||||
textFields.forEach((field) => {
|
||||
if (updatedDoc.data[field] != updatedDoc.previousData[field]) {
|
||||
isDirty.value = true
|
||||
}
|
||||
})
|
||||
|
||||
imageFields.forEach((field) => {
|
||||
if (
|
||||
updatedDoc.data[field] &&
|
||||
updatedDoc.data[field].file_url != updatedDoc.previousData[field].file_url
|
||||
) {
|
||||
isDirty.value = true
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,78 +1,129 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<!-- <div class="text-xs text-ink-gray-5">
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<FormControl
|
||||
v-model="search"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
:debounce="300"
|
||||
/>
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
|
||||
<X v-else class="size-4 stroke-1.5" />
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ showForm ? __('Close') : __('New') }}
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form to add new member -->
|
||||
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
||||
<div class="mt-8 pb-5">
|
||||
<FormControl
|
||||
v-model="email"
|
||||
:placeholder="__('Email')"
|
||||
type="email"
|
||||
class="w-full"
|
||||
@keydown.enter="addEvaluator"
|
||||
/>
|
||||
<Button @click="addEvaluator()" variant="subtle">
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="divide-y">
|
||||
<div
|
||||
v-for="evaluator in evaluators.data"
|
||||
@click="openProfile(evaluator.username)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Avatar
|
||||
:image="evaluator.user_image"
|
||||
:label="evaluator.full_name"
|
||||
size="lg"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-base font-semibold text-ink-gray-9">
|
||||
{{ evaluator.full_name }}
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ evaluator.evaluator }}
|
||||
v-model="search"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
:debounce="300"
|
||||
class="w-1/4 mb-4"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div class="overflow-auto h-[60vh]">
|
||||
<div class="divide-y">
|
||||
<div
|
||||
v-for="evaluator in evaluators.data"
|
||||
:key="evaluator.evaluator"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<div class="flex items-center justify-between group py-3">
|
||||
<div
|
||||
class="flex items-center space-x-3"
|
||||
@click="openProfile(evaluator.username)"
|
||||
>
|
||||
<Avatar
|
||||
:image="evaluator.user_image"
|
||||
:label="evaluator.full_name"
|
||||
size="xl"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="text-base font-semibold text-ink-gray-9">
|
||||
{{ evaluator.full_name }}
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ evaluator.evaluator }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="invisible group-hover:visible">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteEvaluator(evaluator.evaluator)"
|
||||
>
|
||||
<template #icon>
|
||||
<Trash2 class="size-4 stroke-1.5 text-ink-red-3" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="evaluators.length && hasNextPage"
|
||||
class="flex justify-center mt-4"
|
||||
>
|
||||
<Button @click="evaluators.reload()">
|
||||
<template #prefix>
|
||||
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
title: __('Add Evaluator'),
|
||||
actions: [{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick({ close }: any) {
|
||||
addEvaluator(close)
|
||||
},
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div v-if="showForm" class="flex items-center">
|
||||
<FormControl
|
||||
v-model="email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
class="w-full"
|
||||
@keydown.enter="addEvaluator"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const show = defineModel('show')
|
||||
@@ -95,33 +146,39 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const evaluators = createResource({
|
||||
url: 'frappe.client.get_list',
|
||||
makeParams: () => {
|
||||
return {
|
||||
doctype: 'Course Evaluator',
|
||||
fields: ['evaluator', 'full_name', 'user_image', 'username'],
|
||||
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
|
||||
}
|
||||
},
|
||||
const evaluators = createListResource({
|
||||
doctype: 'Course Evaluator',
|
||||
fields: ['evaluator', 'username', 'full_name', 'user_image'],
|
||||
auto: true,
|
||||
orderBy: 'creation desc',
|
||||
})
|
||||
|
||||
const addEvaluator = () => {
|
||||
const addEvaluator = (close: () => void) => {
|
||||
call('lms.lms.api.add_an_evaluator', {
|
||||
email: email.value,
|
||||
}).then((data) => {
|
||||
showForm.value = false
|
||||
email.value = ''
|
||||
evaluators.reload()
|
||||
})
|
||||
.then(() => {
|
||||
email.value = ''
|
||||
evaluators.reload()
|
||||
toast.success(__('Evaluator added successfully'))
|
||||
close()
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error(__(error.messages[0] || error.messages))
|
||||
console.error('Error adding evaluator:', error)
|
||||
})
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
evaluators.update({
|
||||
filters: {
|
||||
full_name: ['like', `%${search.value}%`],
|
||||
},
|
||||
})
|
||||
evaluators.reload()
|
||||
})
|
||||
|
||||
const openProfile = (username) => {
|
||||
const openProfile = (username: string) => {
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'Profile',
|
||||
@@ -130,4 +187,18 @@ const openProfile = (username) => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const deleteEvaluator = (evaluator: string) => {
|
||||
call('lms.lms.api.delete_evaluator', {
|
||||
evaluator: evaluator,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(__('Evaluator deleted successfully'))
|
||||
evaluators.reload()
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error(__(error.messages[0] || error.messages))
|
||||
console.error('Error deleting evaluator:', error)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,53 +5,37 @@
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<!-- <div class="text-xs text-ink-gray-5">
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<FormControl
|
||||
v-model="search"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
:debounce="300"
|
||||
/>
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
|
||||
<X v-else class="size-4 stroke-1.5" />
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ showForm ? __('Close') : __('New') }}
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form to add new member -->
|
||||
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
||||
<div class="mt-8 pb-10">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:placeholder="__('Email')"
|
||||
type="email"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:placeholder="__('First Name')"
|
||||
v-model="search"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
<Button @click="addMember()" variant="subtle">
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 pb-10 overflow-auto">
|
||||
<!-- Member list -->
|
||||
<div class="overflow-y-scroll">
|
||||
:debounce="300"
|
||||
class="w-1/4 mb-4"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div class="overflow-y-scroll h-[60vh]">
|
||||
<ul class="divide-y">
|
||||
<li
|
||||
v-for="member in memberList"
|
||||
class="grid grid-cols-3 gap-10 py-2 cursor-pointer"
|
||||
class="flex items-center justify-between py-2 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
@click="openProfile(member.username)"
|
||||
@@ -60,27 +44,13 @@
|
||||
<Avatar
|
||||
:image="member.user_image"
|
||||
:label="member.full_name"
|
||||
size="lg"
|
||||
size="xl"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="flex">
|
||||
<div class="text-ink-gray-9">
|
||||
{{ member.full_name }}
|
||||
</div>
|
||||
<div
|
||||
class="px-1"
|
||||
v-if="member.role && getRole(member.role) !== 'Student'"
|
||||
>
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:ref_for="true"
|
||||
theme="blue"
|
||||
size="sm"
|
||||
label="Badge"
|
||||
>
|
||||
{{ getRole(member.role) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ member.name }}
|
||||
@@ -88,43 +58,92 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-center text-ink-gray-7 text-sm"
|
||||
class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||
v-if="member.role && member.role !== 'LMS Student'"
|
||||
>
|
||||
<div v-if="member.last_active">
|
||||
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
|
||||
</div>
|
||||
<div v-else>-</div>
|
||||
<Shield class="size-4 stroke-1.5" />
|
||||
<span class="text-sm">
|
||||
{{ getRole(member.role) }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="memberList.length && hasNextPage"
|
||||
class="flex justify-center mt-4"
|
||||
>
|
||||
<Button @click="members.reload()">
|
||||
<template #prefix>
|
||||
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
<div
|
||||
v-if="memberList.length && hasNextPage"
|
||||
class="flex justify-center mt-4"
|
||||
>
|
||||
<Button @click="members.reload()">
|
||||
<template #prefix>
|
||||
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
title: __('Add a new member'),
|
||||
size: 'lg',
|
||||
actions: [{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick({ close }: any) {
|
||||
addMember(close)
|
||||
}
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:label="__('First Name')"
|
||||
placeholder="Jane"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource, Avatar, Button, FormControl, Badge } from 'frappe-ui'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { RefreshCw, Plus, X } from 'lucide-vue-next'
|
||||
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import type { User } from '@/components/Settings/types'
|
||||
|
||||
type Member = {
|
||||
username: string
|
||||
full_name: string
|
||||
name: string
|
||||
role?: string
|
||||
user_image?: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const show = defineModel('show')
|
||||
const search = ref('')
|
||||
const start = ref(0)
|
||||
const memberList = ref([])
|
||||
const memberList = ref<Member[]>([])
|
||||
const hasNextPage = ref(false)
|
||||
const showForm = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
@@ -158,7 +177,7 @@ const members = createResource({
|
||||
start: start.value,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
onSuccess(data: Member[]) {
|
||||
memberList.value = memberList.value.concat(data)
|
||||
start.value = start.value + 20
|
||||
hasNextPage.value = data.length === 20
|
||||
@@ -166,7 +185,7 @@ const members = createResource({
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openProfile = (username) => {
|
||||
const openProfile = (username: string) => {
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'Profile',
|
||||
@@ -178,7 +197,7 @@ const openProfile = (username) => {
|
||||
|
||||
const newMember = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
makeParams() {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
@@ -188,13 +207,12 @@ const newMember = createResource({
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
onSuccess(data: Member) {
|
||||
show.value = false
|
||||
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
|
||||
router.push({
|
||||
name: 'Profile',
|
||||
name: 'ProfileRoles',
|
||||
params: {
|
||||
username: data.username,
|
||||
},
|
||||
@@ -202,8 +220,9 @@ const newMember = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const addMember = () => {
|
||||
const addMember = (close: () => void) => {
|
||||
newMember.reload()
|
||||
close()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
@@ -212,8 +231,8 @@ watch(search, () => {
|
||||
members.reload()
|
||||
})
|
||||
|
||||
const getRole = (role) => {
|
||||
const map = {
|
||||
const getRole = (role: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'LMS Student': 'Student',
|
||||
'Course Creator': 'Instructor',
|
||||
Moderator: 'Moderator',
|
||||
|
||||
@@ -34,32 +34,16 @@
|
||||
:key="activeTab.label"
|
||||
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
|
||||
>
|
||||
<Members
|
||||
v-if="activeTab.label === 'Members'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
v-model:show="show"
|
||||
/>
|
||||
<Evaluators
|
||||
v-else-if="activeTab.label === 'Evaluators'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
v-model:show="show"
|
||||
/>
|
||||
<Categories
|
||||
v-else-if="activeTab.label === 'Categories'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
/>
|
||||
<EmailTemplates
|
||||
v-else-if="activeTab.label === 'Email Templates'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
/>
|
||||
<ZoomSettings
|
||||
v-else-if="activeTab.label === 'Zoom Accounts'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
<component
|
||||
v-if="activeTab.template"
|
||||
:is="activeTab.template"
|
||||
v-bind="{
|
||||
label: activeTab.label,
|
||||
description: activeTab.description,
|
||||
...(activeTab.label === 'Branding'
|
||||
? { fields: activeTab.fields }
|
||||
: {}),
|
||||
}"
|
||||
/>
|
||||
<PaymentSettings
|
||||
v-else-if="activeTab.label === 'Payment Gateway'"
|
||||
@@ -68,13 +52,6 @@
|
||||
:data="data"
|
||||
:fields="activeTab.fields"
|
||||
/>
|
||||
<BrandSettings
|
||||
v-else-if="activeTab.label === 'Branding'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
:fields="activeTab.fields"
|
||||
:data="branding"
|
||||
/>
|
||||
<SettingDetails
|
||||
v-else
|
||||
:fields="activeTab.fields"
|
||||
@@ -88,8 +65,8 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Dialog, createDocumentResource } from 'frappe-ui'
|
||||
import { computed, markRaw, ref, watch } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import SettingDetails from '@/components/Settings/SettingDetails.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
@@ -100,6 +77,7 @@ import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
|
||||
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
|
||||
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||
import Badges from '@/components/Settings/Badges.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const doctype = ref('LMS Settings')
|
||||
@@ -114,12 +92,6 @@ const data = createDocumentResource({
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const branding = createResource({
|
||||
url: 'lms.lms.api.get_branding',
|
||||
auto: true,
|
||||
cache: 'brand',
|
||||
})
|
||||
|
||||
const tabsStructure = computed(() => {
|
||||
return [
|
||||
{
|
||||
@@ -130,6 +102,13 @@ const tabsStructure = computed(() => {
|
||||
label: 'General',
|
||||
icon: 'Wrench',
|
||||
fields: [
|
||||
{
|
||||
label: 'Allow Guest Access',
|
||||
name: 'allow_guest_access',
|
||||
description:
|
||||
'If enabled, users can access the course and batch lists without logging in.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Enable Learning Paths',
|
||||
name: 'enable_learning_paths',
|
||||
@@ -138,11 +117,11 @@ const tabsStructure = computed(() => {
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Allow Guest Access',
|
||||
name: 'allow_guest_access',
|
||||
description:
|
||||
'If enabled, users can access the course and batch lists without logging in.',
|
||||
label: 'Prevent Skipping Videos',
|
||||
name: 'prevent_skipping_videos',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, users will no able to move forward in a video',
|
||||
},
|
||||
{
|
||||
label: 'Send calendar invite for evaluations',
|
||||
@@ -154,6 +133,14 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Livecode URL',
|
||||
name: 'livecode_url',
|
||||
doctype: 'Livecode URL',
|
||||
type: 'text',
|
||||
description:
|
||||
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
|
||||
},
|
||||
{
|
||||
label: 'Batch Confirmation Email Template',
|
||||
name: 'batch_confirmation_template',
|
||||
@@ -227,38 +214,55 @@ const tabsStructure = computed(() => {
|
||||
items: [
|
||||
{
|
||||
label: 'Members',
|
||||
description: 'Manage the members of your learning system',
|
||||
description:
|
||||
'Add new members or manage roles and permissions of existing members',
|
||||
icon: 'UserRoundPlus',
|
||||
template: markRaw(Members),
|
||||
},
|
||||
{
|
||||
label: 'Evaluators',
|
||||
description: 'Manage the evaluators of your learning system',
|
||||
description: '',
|
||||
icon: 'UserCheck',
|
||||
description:
|
||||
'Add new evaluators or check the slots existing evaluators',
|
||||
template: markRaw(Evaluators),
|
||||
},
|
||||
{
|
||||
label: 'Zoom Accounts',
|
||||
description:
|
||||
'Manage zoom accounts to conduct live classes from batches',
|
||||
icon: 'Video',
|
||||
template: markRaw(ZoomSettings),
|
||||
},
|
||||
{
|
||||
label: 'Badges',
|
||||
description:
|
||||
'Create badges and assign them to students to acknowledge their achievements',
|
||||
icon: 'Award',
|
||||
template: markRaw(Badges),
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
description: 'Double click to edit the category',
|
||||
icon: 'Network',
|
||||
template: markRaw(Categories),
|
||||
},
|
||||
{
|
||||
label: 'Email Templates',
|
||||
description: 'Manage the email templates for your learning system',
|
||||
icon: 'MailPlus',
|
||||
},
|
||||
{
|
||||
label: 'Zoom Accounts',
|
||||
description: 'Manage the Zoom accounts for your learning system',
|
||||
icon: 'Video',
|
||||
template: markRaw(EmailTemplates),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Customise',
|
||||
label: 'Customize',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Branding',
|
||||
icon: 'Blocks',
|
||||
template: markRaw(BrandSettings),
|
||||
fields: [
|
||||
{
|
||||
label: 'Brand Name',
|
||||
@@ -292,6 +296,11 @@ const tabsStructure = computed(() => {
|
||||
name: 'batches',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Programming Exercises',
|
||||
name: 'programming_exercises',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Certified Members',
|
||||
name: 'certified_members',
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ label }}
|
||||
</div>
|
||||
<!-- <div class="text-xs text-ink-gray-5">
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<Button @click="openForm('new')">
|
||||
@@ -35,10 +35,10 @@
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
:name="item.icon"
|
||||
class="h-4 w-4 stroke-1.5"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
@@ -48,8 +48,18 @@
|
||||
<ListRow :row="row" v-for="row in zoomAccounts.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="column.key == 'enabled'">
|
||||
<Badge v-if="row[column.key]" theme="blue">
|
||||
<Badge v-if="row[column.key]" theme="green">
|
||||
{{ __('Enabled') }}
|
||||
</Badge>
|
||||
<Badge v-else theme="gray">
|
||||
@@ -87,10 +97,12 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Badge,
|
||||
call,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
@@ -122,6 +134,7 @@ const zoomAccounts = createListResource({
|
||||
'enabled',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'account_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
@@ -170,18 +183,21 @@ const removeAccount = (selections, unselectAll) => {
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Account'),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Account Name'),
|
||||
key: 'name',
|
||||
icon: 'video',
|
||||
},
|
||||
{
|
||||
label: __('Status'),
|
||||
key: 'enabled',
|
||||
align: 'center',
|
||||
icon: 'check-square',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -13,4 +13,62 @@ export interface User {
|
||||
is_instructor: boolean
|
||||
is_fc_site: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface Badge {
|
||||
name: string;
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
image: string;
|
||||
grant_only_once: boolean;
|
||||
event: string;
|
||||
reference_doctype: string;
|
||||
condition: string;
|
||||
user_field: string;
|
||||
field_to_check: string;
|
||||
};
|
||||
|
||||
export interface Badges {
|
||||
data: Badge[],
|
||||
reload: () => void
|
||||
insert: {
|
||||
submit: (
|
||||
data: Badge,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
},
|
||||
setValue: {
|
||||
submit: (
|
||||
data: Badge,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
},
|
||||
}
|
||||
|
||||
export interface BadgeAssignment {
|
||||
name: string;
|
||||
member: string;
|
||||
member_name: string;
|
||||
member_username: string;
|
||||
member_image: string;
|
||||
badge: string;
|
||||
issued_on: string;
|
||||
}
|
||||
|
||||
export interface BadgeAssignments {
|
||||
data: BadgeAssignment[],
|
||||
reload: () => void
|
||||
insert: {
|
||||
submit: (
|
||||
data: BadgeAssignment,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
},
|
||||
setValue: {
|
||||
submit: (
|
||||
data: BadgeAssignment,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
},
|
||||
}
|
||||
@@ -116,7 +116,7 @@ import {
|
||||
EllipsisVertical,
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref, getCurrentInstance, computed } from 'vue'
|
||||
import { formatTime } from '../utils'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Button, createResource, call } from 'frappe-ui'
|
||||
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="quizzes.length && !showQuiz && readOnly"
|
||||
class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
||||
>
|
||||
<div v-if="quizzes.length && !showQuiz && readOnly" class="leading-5">
|
||||
{{
|
||||
__('This video contains {0} {1}:').format(
|
||||
quizzes.length,
|
||||
@@ -12,8 +9,10 @@
|
||||
}}
|
||||
|
||||
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
|
||||
<span> {{ index + 1 }}. {{ quiz.quiz }} </span>
|
||||
{{ __('at {0}').format(formatTimestamp(quiz.time)) }}
|
||||
<span>
|
||||
{{ index + 1 }}. <span class="font-semibold"> {{ quiz.quiz }} </span>
|
||||
</span>
|
||||
{{ __('at {0} minutes').format(formatTimestamp(quiz.time)) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -28,9 +27,9 @@
|
||||
oncontextmenu="return false"
|
||||
class="rounded-md border border-gray-100 cursor-pointer"
|
||||
ref="videoRef"
|
||||
>
|
||||
<source :src="fileURL" :type="type" />
|
||||
</video>
|
||||
:src="fileURL"
|
||||
:type="type"
|
||||
></video>
|
||||
<div
|
||||
v-if="!playing"
|
||||
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
||||
@@ -65,15 +64,28 @@
|
||||
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
||||
</template>
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration"
|
||||
step="0.1"
|
||||
v-model="currentTime"
|
||||
@input="changeCurrentTime"
|
||||
class="duration-slider w-full h-1"
|
||||
/>
|
||||
|
||||
<div class="relative flex items-center w-full flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration"
|
||||
step="0.1"
|
||||
v-model="currentTime"
|
||||
@input="changeCurrentTime"
|
||||
class="duration-slider h-1"
|
||||
/>
|
||||
<!-- QUIZ MARKERS -->
|
||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
|
||||
<div
|
||||
v-for="(quiz, index) in quizzes"
|
||||
:key="index"
|
||||
:style="getQuizMarkerStyle(quiz.time)"
|
||||
class="absolute top-0 h-full w-2 bg-surface-amber-3"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-sm font-medium">
|
||||
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
|
||||
</span>
|
||||
@@ -116,12 +128,34 @@
|
||||
:saveQuizzes="saveQuizzes"
|
||||
:duration="duration"
|
||||
/>
|
||||
<Dialog
|
||||
v-model="showQuizLoader"
|
||||
:options="{
|
||||
size: 'sm',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col space-y-2 p-5 text-base leading-5">
|
||||
<span class="font-semibold">
|
||||
{{ __('Time for a Quiz') }}
|
||||
</span>
|
||||
<span>
|
||||
{{
|
||||
__(
|
||||
'Complete the upcoming quiz to continue watching the video. The quiz will open in {0} {1}.'
|
||||
).format(quizLoadTimer, quizLoadTimer === 1 ? 'second' : 'seconds')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
import { Button } from 'frappe-ui'
|
||||
import { Button, Dialog } from 'frappe-ui'
|
||||
import { formatSeconds, formatTimestamp } from '@/utils'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import Play from '@/components/Icons/Play.vue'
|
||||
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
|
||||
|
||||
@@ -133,8 +167,11 @@ let duration = ref(0)
|
||||
let muted = ref(false)
|
||||
const showQuizModal = ref(false)
|
||||
const showQuiz = ref(false)
|
||||
const showQuizLoader = ref(false)
|
||||
const quizLoadTimer = ref(0)
|
||||
const currentQuiz = ref(null)
|
||||
const nextQuiz = ref({})
|
||||
const { preventSkippingVideos } = useSettings()
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
@@ -146,7 +183,7 @@ const props = defineProps({
|
||||
default: 'video/mp4',
|
||||
},
|
||||
readOnly: {
|
||||
type: String,
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
quizzes: {
|
||||
@@ -155,6 +192,7 @@ const props = defineProps({
|
||||
},
|
||||
saveQuizzes: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -175,12 +213,24 @@ const updateCurrentTime = () => {
|
||||
playing.value = false
|
||||
videoRef.value.onTimeupdate = null
|
||||
currentQuiz.value = nextQuiz.value.quiz
|
||||
showQuiz.value = true
|
||||
quizLoadTimer.value = 7
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
watch(quizLoadTimer, () => {
|
||||
if (quizLoadTimer.value > 0) {
|
||||
showQuizLoader.value = true
|
||||
setTimeout(() => {
|
||||
quizLoadTimer.value -= 1
|
||||
}, 1000)
|
||||
} else {
|
||||
showQuizLoader.value = false
|
||||
showQuiz.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const resumeVideo = (restart = false) => {
|
||||
showQuiz.value = false
|
||||
currentQuiz.value = null
|
||||
@@ -248,6 +298,11 @@ const toggleMute = () => {
|
||||
}
|
||||
|
||||
const changeCurrentTime = () => {
|
||||
if (
|
||||
preventSkippingVideos.data &&
|
||||
currentTime.value > videoRef.value.currentTime
|
||||
)
|
||||
return
|
||||
videoRef.value.currentTime = currentTime.value
|
||||
updateNextQuiz()
|
||||
}
|
||||
@@ -259,6 +314,13 @@ const toggleFullscreen = () => {
|
||||
videoContainer.value.requestFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
const getQuizMarkerStyle = (time) => {
|
||||
const percentage = ((time - 5) / Math.ceil(duration.value)) * 100
|
||||
return {
|
||||
left: `${percentage}%`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -278,11 +340,10 @@ iframe {
|
||||
}
|
||||
|
||||
.duration-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 10px;
|
||||
background-color: theme('colors.gray.100');
|
||||
background-color: theme('colors.gray.600');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -290,20 +351,20 @@ iframe {
|
||||
width: 2px;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
background-color: theme('colors.gray.500');
|
||||
background-color: theme('colors.white');
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
input[type='range'] {
|
||||
overflow: hidden;
|
||||
width: 150px;
|
||||
width: 100%;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
box-shadow: -500px 0 0 500px theme('colors.gray.600');
|
||||
box-shadow: -500px 0 0 500px theme('colors.white');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
11
frontend/src/global.d.ts
vendored
Normal file
11
frontend/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
function __(text: string): string
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
__: (text: string) => string
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
@import './assets/Inter/inter.css';
|
||||
@import 'frappe-ui/src/style.css';
|
||||
@import './styles/codemirror.css';
|
||||
@@ -145,7 +145,6 @@ const submissions = createListResource({
|
||||
},
|
||||
})
|
||||
|
||||
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
|
||||
watch([assignmentID, member, status], () => {
|
||||
router.push({
|
||||
query: {
|
||||
|
||||
@@ -16,20 +16,17 @@
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div
|
||||
v-if="assignmentCount"
|
||||
class="text-xl font-semibold text-ink-gray-7 mb-4"
|
||||
>
|
||||
<div v-if="assignmentCount" class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('{0} Assignments').format(assignmentCount) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="assignments.data?.length || assigmentCount > 0"
|
||||
v-if="assignments.data?.length || assignmentCount > 0"
|
||||
class="grid grid-cols-2 gap-5"
|
||||
>
|
||||
<FormControl
|
||||
|
||||
@@ -4,9 +4,16 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<Button variant="solid" @click="saveBatch()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button v-if="batchDetail.data?.name" @click="deleteBatch">
|
||||
<template #icon>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveBatch()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||
@@ -209,7 +216,10 @@
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-5 px-20">
|
||||
<div
|
||||
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
|
||||
@click="openFileSelector"
|
||||
>
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
@@ -300,10 +310,11 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
getCurrentInstance,
|
||||
inject,
|
||||
reactive,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
reactive,
|
||||
ref,
|
||||
} from 'vue'
|
||||
import {
|
||||
@@ -315,21 +326,30 @@ import {
|
||||
createResource,
|
||||
usePageMeta,
|
||||
toast,
|
||||
call,
|
||||
Toast,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Image } from 'lucide-vue-next'
|
||||
import { Image, Trash2 } from 'lucide-vue-next'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
|
||||
import {
|
||||
openSettings,
|
||||
getMetaInfo,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
} from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const instructors = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
@@ -531,6 +551,38 @@ const editBatchDetails = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const deleteBatch = () => {
|
||||
$dialog({
|
||||
title: __('Confirm your action to delete'),
|
||||
message: __(
|
||||
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick({ close }) {
|
||||
trashBatch(close)
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const trashBatch = (close) => {
|
||||
call('lms.lms.api.delete_batch', {
|
||||
batch: props.batchName,
|
||||
}).then(() => {
|
||||
toast.success(__('Batch deleted successfully'))
|
||||
close()
|
||||
router.push({
|
||||
name: 'Batches',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const saveImage = (file) => {
|
||||
batch.image = file
|
||||
}
|
||||
@@ -539,13 +591,6 @@ const removeImage = () => {
|
||||
batch.image = null
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</header>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
/>
|
||||
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
||||
<FormControl
|
||||
:label="__('State')"
|
||||
:label="__('State/Province')"
|
||||
v-model="billingDetails.state"
|
||||
/>
|
||||
</div>
|
||||
@@ -303,6 +303,7 @@ const validateAddress = () => {
|
||||
'Gujarat',
|
||||
'Haryana',
|
||||
'Himachal Pradesh',
|
||||
'Jammu and Kashmir',
|
||||
'Jharkhand',
|
||||
'Karnataka',
|
||||
'Kerala',
|
||||
|
||||
@@ -132,6 +132,7 @@ const participants = createListResource({
|
||||
doctype: 'LMS Certificate',
|
||||
url: 'lms.lms.api.get_certified_participants',
|
||||
start: 0,
|
||||
cache: ['certified_participants'],
|
||||
pageLength: 100,
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="flex justify-between w-full space-x-5">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ course.data.title }}
|
||||
@@ -66,7 +66,9 @@
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
|
||||
<div class="md:hidden mb-4">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
<div
|
||||
v-html="course.data.description"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||
@@ -76,6 +78,7 @@
|
||||
:title="__('Course Outline')"
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
:getProgress="course.data.membership ? true : false"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
@@ -88,6 +91,7 @@
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
</div>
|
||||
<RelatedCourses :courseName="course.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -99,7 +103,7 @@ import {
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { Users, Star } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||
@@ -107,6 +111,7 @@ import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import CourseReviews from '@/components/CourseReviews.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
|
||||
@@ -120,12 +125,21 @@ const props = defineProps({
|
||||
const course = createResource({
|
||||
url: 'lms.lms.utils.get_course_details',
|
||||
cache: ['course', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
course.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
|
||||
@@ -47,48 +47,37 @@
|
||||
:required="true"
|
||||
/>
|
||||
<div>
|
||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __('Tags') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md mr-2"
|
||||
>
|
||||
{{ tag }}
|
||||
<X
|
||||
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
|
||||
@click="removeTag(tag)"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="newTag"
|
||||
:placeholder="__('Add a keyword and then press enter')"
|
||||
:class="['w-full', 'flex-1', 'my-1']"
|
||||
@keyup.enter="updateTags()"
|
||||
id="tags"
|
||||
/>
|
||||
<div>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||
>
|
||||
{{ tag }}
|
||||
<X
|
||||
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
|
||||
@click="removeTag(tag)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="newTag"
|
||||
:placeholder="__('Add a keyword and then press enter')"
|
||||
class="w-full"
|
||||
@keyup.enter="updateTags()"
|
||||
id="tags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
:label="__('Short Introduction')"
|
||||
:placeholder="
|
||||
__(
|
||||
'A one line introduction to the course that appears on the course card'
|
||||
)
|
||||
"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __('Course Image') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!course.course_image"
|
||||
@@ -100,7 +89,10 @@
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-5 px-20">
|
||||
<div
|
||||
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
|
||||
@click="openFileSelector"
|
||||
>
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
@@ -135,6 +127,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ColorSwatches
|
||||
v-model="course.card_gradient"
|
||||
:label="__('Color')"
|
||||
:description="__('Choose a color for the course card')"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,6 +175,21 @@
|
||||
</div>
|
||||
|
||||
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('About the Course') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
:label="__('Short Introduction')"
|
||||
:placeholder="
|
||||
__(
|
||||
'A one line introduction to the course that appears on the course card'
|
||||
)
|
||||
"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Course Description') }}
|
||||
@@ -199,6 +213,21 @@
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-model="related_courses"
|
||||
doctype="LMS Course"
|
||||
:label="__('Related Courses')"
|
||||
:filters="{ name: ['!=', courseResource.data?.name] }"
|
||||
:onCreate="
|
||||
(close) => {
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: 'new' },
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-10 pb-5 space-y-5 border-b">
|
||||
@@ -309,16 +338,23 @@ import { useRouter } from 'vue-router'
|
||||
import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
|
||||
import {
|
||||
openSettings,
|
||||
getMetaInfo,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
} from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import ColorSwatches from '@/components/Controls/ColorSwatches.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const newTag = ref('')
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const instructors = ref([])
|
||||
const related_courses = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
@@ -335,6 +371,7 @@ const course = reactive({
|
||||
description: '',
|
||||
video_link: '',
|
||||
course_image: null,
|
||||
card_gradient: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
published: false,
|
||||
@@ -400,6 +437,9 @@ const courseCreationResource = createResource({
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
related_courses: related_courses.value.map((course) => ({
|
||||
course: course,
|
||||
})),
|
||||
...values,
|
||||
},
|
||||
}
|
||||
@@ -418,6 +458,9 @@ const courseEditResource = createResource({
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
related_courses: related_courses.value.map((course) => ({
|
||||
course: course,
|
||||
})),
|
||||
...course,
|
||||
},
|
||||
}
|
||||
@@ -440,6 +483,11 @@ const courseResource = createResource({
|
||||
data.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (key == 'related_courses') {
|
||||
related_courses.value = []
|
||||
data.related_courses.forEach((course) => {
|
||||
related_courses.value.push(course.course)
|
||||
})
|
||||
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||
})
|
||||
let checkboxes = [
|
||||
@@ -564,13 +612,6 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
||||
return __('Only image file is allowed.')
|
||||
}
|
||||
}
|
||||
|
||||
const updateTags = () => {
|
||||
if (newTag.value) {
|
||||
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</header>
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="courses.data?.length"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-8"
|
||||
>
|
||||
<router-link
|
||||
v-for="course in courses.data"
|
||||
|
||||
@@ -151,7 +151,7 @@ import { computed, onMounted, reactive, inject } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getFileSize } from '@/utils'
|
||||
import { getFileSize, validateFile } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
@@ -293,13 +293,6 @@ const removeImage = () => {
|
||||
job.image = null
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const jobTypes = computed(() => {
|
||||
return [
|
||||
{ label: 'Full Time', value: 'Full Time' },
|
||||
|
||||
@@ -122,9 +122,6 @@ onMounted(() => {
|
||||
const jobs = createResource({
|
||||
url: 'lms.lms.api.get_job_opportunities',
|
||||
cache: ['jobs'],
|
||||
onSuccess(data) {
|
||||
jobCount.value = data.length
|
||||
},
|
||||
})
|
||||
|
||||
const updateJobs = () => {
|
||||
@@ -169,6 +166,10 @@ watch(country, (val) => {
|
||||
updateJobs()
|
||||
})
|
||||
|
||||
watch(jobs, () => {
|
||||
jobCount.value = jobs.data?.length || 0
|
||||
})
|
||||
|
||||
const jobTypes = computed(() => {
|
||||
return [
|
||||
'',
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
</template>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button v-if="canSeeStats()" @click="showVideoStats()">
|
||||
<template #prefix>
|
||||
<TrendingUp class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Video Statistics') }}
|
||||
</Button>
|
||||
<CertificationLinks :courseName="courseName" />
|
||||
</div>
|
||||
</header>
|
||||
@@ -63,55 +69,48 @@
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="border-r container pt-5 pb-10 px-5 h-full"
|
||||
class="border-r pt-5 pb-10 h-full"
|
||||
:class="{
|
||||
'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
|
||||
'w-full md:w-3/5 mx-auto border-none !pt-10': zenModeEnabled,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center justify-between"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ lesson.data.title }}
|
||||
</div>
|
||||
<div class="px-5">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center justify-between"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ lesson.data.title }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="zenModeEnabled"
|
||||
class="relative flex items-center space-x-2 text-sm mt-1 text-ink-gray-7 group w-fit mt-2"
|
||||
>
|
||||
<span>
|
||||
{{ lesson.data.chapter_title }} -
|
||||
{{ lesson.data.course_title }}
|
||||
</span>
|
||||
<Info class="size-3" />
|
||||
<div
|
||||
class="hidden group-hover:block rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-xl absolute left-0 top-full mt-2"
|
||||
v-if="zenModeEnabled"
|
||||
class="relative flex items-center space-x-2 text-sm mt-1 text-ink-gray-7 group w-fit mt-2"
|
||||
>
|
||||
{{ Math.ceil(lesson.data.membership.progress) }}%
|
||||
{{ __('completed') }}
|
||||
<span>
|
||||
{{ lesson.data.chapter_title }} -
|
||||
{{ lesson.data.course_title }}
|
||||
</span>
|
||||
<Info class="size-3" />
|
||||
<div
|
||||
class="hidden group-hover:block rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-xl absolute left-0 top-full mt-2"
|
||||
>
|
||||
{{ Math.ceil(lesson.data.membership.progress) }}%
|
||||
{{ __('completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 mt-2 md:mt-0">
|
||||
<Button v-if="zenModeEnabled" @click="showDiscussionsInZenMode()">
|
||||
<template #icon>
|
||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="lesson.data.prev"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.prev.split('.')[0],
|
||||
lessonNumber: lesson.data.prev.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<div class="flex items-center space-x-2 mt-2 md:mt-0">
|
||||
<Button
|
||||
v-if="zenModeEnabled"
|
||||
@click="showDiscussionsInZenMode()"
|
||||
>
|
||||
<template #icon>
|
||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
|
||||
<template #prefix>
|
||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
@@ -119,34 +118,24 @@
|
||||
{{ __('Previous') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="allowEdit()"
|
||||
:to="{
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: props.chapterNumber,
|
||||
lessonNumber: props.lessonNumber,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="lesson.data.next"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.next.split('.')[0],
|
||||
lessonNumber: lesson.data.next.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
|
||||
<router-link
|
||||
v-if="allowEdit()"
|
||||
:to="{
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: props.chapterNumber,
|
||||
lessonNumber: props.lessonNumber,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
|
||||
<Button v-if="lesson.data.next" @click="switchLesson('next')">
|
||||
<template #suffix>
|
||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
@@ -154,85 +143,106 @@
|
||||
{{ __('Next') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: courseName },
|
||||
|
||||
<router-link
|
||||
v-else
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: courseName },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Back to Course') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!zenModeEnabled" class="flex items-center mt-2">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': lesson.data.instructors?.length > 1,
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Back to Course') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!zenModeEnabled" class="flex items-center mt-2">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': lesson.data.instructors?.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in lesson.data.instructors"
|
||||
:user="instructor"
|
||||
<UserAvatar
|
||||
v-for="instructor in lesson.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors
|
||||
v-if="lesson.data?.instructors"
|
||||
:instructors="lesson.data.instructors"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors
|
||||
v-if="lesson.data?.instructors"
|
||||
:instructors="lesson.data.instructors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
lesson.data.instructor_content &&
|
||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
||||
allowInstructorContent()
|
||||
"
|
||||
class="bg-surface-gray-2 p-3 rounded-md mt-6"
|
||||
>
|
||||
<div class="text-ink-gray-5 font-medium">
|
||||
{{ __('Instructor Notes') }}
|
||||
<div
|
||||
v-if="
|
||||
lesson.data.instructor_content &&
|
||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length >
|
||||
1 &&
|
||||
allowInstructorContent()
|
||||
"
|
||||
class="bg-surface-gray-2 p-3 rounded-md mt-6"
|
||||
>
|
||||
<div class="text-ink-gray-5 font-medium">
|
||||
{{ __('Instructor Notes') }}
|
||||
</div>
|
||||
<div
|
||||
id="instructor-content"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
id="instructor-content"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
v-else-if="lesson.data.instructor_notes"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<LessonContent :content="lesson.data.instructor_notes" />
|
||||
</div>
|
||||
<div
|
||||
v-if="lesson.data.content"
|
||||
@mouseup="toggleInlineMenu"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<LessonContent
|
||||
v-if="lesson.data?.body"
|
||||
:content="lesson.data.body"
|
||||
:youtube="lesson.data.youtube"
|
||||
:quizId="lesson.data.quiz_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="lesson.data.instructor_notes"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
v-if="lesson.data"
|
||||
class="mt-10 pt-5 border-t px-5"
|
||||
ref="discussionsContainer"
|
||||
>
|
||||
<LessonContent :content="lesson.data.instructor_notes" />
|
||||
</div>
|
||||
<div
|
||||
v-if="lesson.data.content"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<LessonContent
|
||||
v-if="lesson.data?.body"
|
||||
:content="lesson.data.body"
|
||||
:youtube="lesson.data.youtube"
|
||||
:quizId="lesson.data.quiz_id"
|
||||
<TabButtons
|
||||
:buttons="tabs"
|
||||
v-model="currentTab"
|
||||
class="w-fit mb-10"
|
||||
/>
|
||||
<Notes
|
||||
v-if="currentTab === 'Notes'"
|
||||
:lesson="lesson.data?.name"
|
||||
v-model:notes="notes"
|
||||
@updateNotes="updateNotes"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-20" ref="discussionsContainer">
|
||||
<Discussions
|
||||
v-if="allowDiscussions"
|
||||
v-else-if="allowDiscussions"
|
||||
:title="'Questions'"
|
||||
:doctype="'Course Lesson'"
|
||||
:docname="lesson.data.name"
|
||||
:key="lesson.data.name"
|
||||
:emptyStateText="
|
||||
__('Ask a question to get help from the community.')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,13 +272,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InlineLessonMenu
|
||||
v-if="lesson.data"
|
||||
v-model="showInlineMenu"
|
||||
:lesson="lesson.data?.name"
|
||||
v-model:notes="notes"
|
||||
@updateNotes="updateNotes"
|
||||
/>
|
||||
<VideoStatistics
|
||||
v-model="showStatsDialog"
|
||||
:lessonName="lesson.data?.name"
|
||||
:lessonTitle="lesson.data?.title"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createResource,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
createResource,
|
||||
TabButtons,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
@@ -281,8 +306,6 @@ import {
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
} from 'vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
ChevronLeft,
|
||||
@@ -292,15 +315,22 @@ import {
|
||||
Focus,
|
||||
Info,
|
||||
MessageCircleQuestion,
|
||||
TrendingUp,
|
||||
} from 'lucide-vue-next'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import { getEditorTools, enablePlyr } from '@/utils'
|
||||
import { getEditorTools, enablePlyr, highlightText } from '@/utils'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSidebar } from '@/stores/sidebar'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import LessonContent from '@/components/LessonContent.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
import VideoStatistics from '@/components/Modals/VideoStatistics.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import Notes from '@/components/Notes/Notes.vue'
|
||||
import InlineLessonMenu from '@/components/Notes/InlineLessonMenu.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const socket = inject('$socket')
|
||||
@@ -312,12 +342,24 @@ const instructorEditor = ref(null)
|
||||
const lessonProgress = ref(0)
|
||||
const lessonContainer = ref(null)
|
||||
const zenModeEnabled = ref(false)
|
||||
const showStatsDialog = ref(false)
|
||||
const hasQuiz = ref(false)
|
||||
const discussionsContainer = ref(null)
|
||||
const timer = ref(0)
|
||||
const { brand } = sessionStore()
|
||||
const sidebarStore = useSidebar()
|
||||
const plyrSources = ref([])
|
||||
const showInlineMenu = ref(false)
|
||||
const currentTab = ref('Notes')
|
||||
let timerInterval
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
label: __('Notes'),
|
||||
value: 'Notes',
|
||||
},
|
||||
])
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
@@ -335,6 +377,7 @@ const props = defineProps({
|
||||
|
||||
onMounted(() => {
|
||||
startTimer()
|
||||
sidebarStore.isSidebarCollapsed = true
|
||||
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
||||
socket.on('update_lesson_progress', (data) => {
|
||||
if (data.course === props.courseName) {
|
||||
@@ -357,6 +400,8 @@ const attachFullscreenEvent = () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
||||
sidebarStore.isSidebarCollapsed = false
|
||||
trackVideoWatchDuration()
|
||||
})
|
||||
|
||||
const lesson = createResource({
|
||||
@@ -392,16 +437,22 @@ const setupLesson = (data) => {
|
||||
editor.value?.isReady.then(() => {
|
||||
checkIfDiscussionsAllowed()
|
||||
})
|
||||
checkQuiz()
|
||||
}
|
||||
|
||||
if (!editor.value && data.body) {
|
||||
const checkQuiz = () => {
|
||||
if (!editor.value && lesson.body) {
|
||||
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
|
||||
hasQuiz.value = quizRegex.test(data.body)
|
||||
if (!hasQuiz.value && !zenModeEnabled) allowDiscussions.value = true
|
||||
hasQuiz.value = quizRegex.test(lesson.body)
|
||||
if (!hasQuiz.value && !zenModeEnabled) {
|
||||
allowDiscussions.value = true
|
||||
} else {
|
||||
allowDiscussions.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderEditor = (holder, content) => {
|
||||
// empty the holder
|
||||
if (document.getElementById(holder))
|
||||
document.getElementById(holder).innerHTML = ''
|
||||
return new EditorJS({
|
||||
@@ -409,7 +460,7 @@ const renderEditor = (holder, content) => {
|
||||
tools: getEditorTools(),
|
||||
data: JSON.parse(content),
|
||||
readOnly: true,
|
||||
defaultBlock: 'embed', // editor adds an empty block at the top, so to avoid that added default block as embed
|
||||
defaultBlock: 'embed',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -432,6 +483,23 @@ const progress = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const notes = createListResource({
|
||||
doctype: 'LMS Lesson Note',
|
||||
filters: {
|
||||
lesson: lesson.data?.name,
|
||||
member: user.data?.name,
|
||||
},
|
||||
fields: ['name', 'color', 'highlighted_text', 'note'],
|
||||
cache: ['notes', lesson.data?.name, user.data?.name],
|
||||
onSuccess(data) {
|
||||
data.forEach((note) => {
|
||||
setTimeout(() => {
|
||||
highlightText(note)
|
||||
}, 500)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
@@ -452,38 +520,163 @@ const breadcrumbs = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const switchLesson = (direction) => {
|
||||
trackVideoWatchDuration()
|
||||
let lessonIndex =
|
||||
direction === 'prev'
|
||||
? lesson.data.prev.split('.')
|
||||
: lesson.data.next.split('.')
|
||||
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: props.courseName,
|
||||
chapterNumber: lessonIndex[0],
|
||||
lessonNumber: lessonIndex[1],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
||||
(
|
||||
async (
|
||||
[newChapterNumber, newLessonNumber],
|
||||
[oldChapterNumber, oldLessonNumber]
|
||||
) => {
|
||||
if (newChapterNumber || newLessonNumber) {
|
||||
editor.value = null
|
||||
instructorEditor.value = null
|
||||
allowDiscussions.value = false
|
||||
lesson.submit({
|
||||
chapter: newChapterNumber,
|
||||
lesson: newLessonNumber,
|
||||
})
|
||||
clearInterval(timerInterval)
|
||||
timer.value = 0
|
||||
plyrSources.value = []
|
||||
await nextTick()
|
||||
resetLessonState(newChapterNumber, newLessonNumber)
|
||||
startTimer()
|
||||
enablePlyr()
|
||||
updateNotes()
|
||||
checkIfDiscussionsAllowed()
|
||||
checkQuiz()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const resetLessonState = (newChapterNumber, newLessonNumber) => {
|
||||
editor.value = null
|
||||
instructorEditor.value = null
|
||||
allowDiscussions.value = false
|
||||
lesson.submit({
|
||||
chapter: newChapterNumber,
|
||||
lesson: newLessonNumber,
|
||||
})
|
||||
clearInterval(timerInterval)
|
||||
timer.value = 0
|
||||
}
|
||||
|
||||
const trackVideoWatchDuration = () => {
|
||||
if (!lesson.data.membership) return
|
||||
let videoDetails = getVideoDetails()
|
||||
videoDetails = videoDetails.concat(getPlyrSourceDetails())
|
||||
call('lms.lms.api.track_video_watch_duration', {
|
||||
lesson: lesson.data.name,
|
||||
videos: videoDetails,
|
||||
})
|
||||
}
|
||||
|
||||
const getVideoDetails = () => {
|
||||
let details = []
|
||||
const videos = document.querySelectorAll('video')
|
||||
if (videos.length > 0) {
|
||||
videos.forEach((video) => {
|
||||
if (video.currentTime == video.duration) markProgress()
|
||||
details.push({
|
||||
source: video.src,
|
||||
watch_time: video.currentTime,
|
||||
})
|
||||
})
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
const getPlyrSourceDetails = () => {
|
||||
let details = []
|
||||
plyrSources.value.forEach((source) => {
|
||||
if (source.currentTime == source.duration) markProgress()
|
||||
let src = cleanYouTubeUrl(source.source)
|
||||
details.push({
|
||||
source: src,
|
||||
watch_time: source.currentTime,
|
||||
})
|
||||
})
|
||||
return details
|
||||
}
|
||||
|
||||
const cleanYouTubeUrl = (url) => {
|
||||
if (!url) return url
|
||||
const urlObj = new URL(url)
|
||||
urlObj.searchParams.delete('t')
|
||||
return urlObj.toString()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => lesson.data,
|
||||
(data) => {
|
||||
async (data) => {
|
||||
setupLesson(data)
|
||||
enablePlyr()
|
||||
getPlyrSource()
|
||||
updateNotes()
|
||||
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
|
||||
}
|
||||
)
|
||||
|
||||
const getPlyrSource = async () => {
|
||||
await nextTick()
|
||||
if (plyrSources.value.length == 0) {
|
||||
plyrSources.value = await enablePlyr()
|
||||
}
|
||||
updateVideoWatchDuration()
|
||||
}
|
||||
|
||||
const updateVideoWatchDuration = () => {
|
||||
if (lesson.data.videos && lesson.data.videos.length > 0) {
|
||||
lesson.data.videos.forEach((video) => {
|
||||
if (video.source.includes('youtube') || video.source.includes('vimeo')) {
|
||||
updatePlyrVideoTime(video)
|
||||
} else {
|
||||
updateVideoTime(video)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updatePlyrVideoTime = (video) => {
|
||||
plyrSources.value.forEach((plyrSource) => {
|
||||
let lastWatchedTime = 0
|
||||
let isSeeking = false
|
||||
|
||||
plyrSource.on('ready', () => {
|
||||
if (plyrSource.source === video.source) {
|
||||
plyrSource.embed.seekTo(video.watch_time, true)
|
||||
plyrSource.play()
|
||||
plyrSource.pause()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const updateVideoTime = (video) => {
|
||||
const videos = document.querySelectorAll('video')
|
||||
if (videos.length > 0) {
|
||||
videos.forEach((vid) => {
|
||||
if (vid.src === video.source) {
|
||||
let watch_time = video.watch_time < vid.duration ? video.watch_time : 0
|
||||
if (vid.readyState >= 1) {
|
||||
vid.currentTime = watch_time
|
||||
} else {
|
||||
vid.addEventListener('loadedmetadata', () => {
|
||||
vid.currentTime = watch_time
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const startTimer = () => {
|
||||
timerInterval = setInterval(() => {
|
||||
let timerInterval = setInterval(() => {
|
||||
timer.value++
|
||||
if (timer.value == 30) {
|
||||
clearInterval(timerInterval)
|
||||
@@ -497,8 +690,11 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
const checkIfDiscussionsAllowed = () => {
|
||||
hasQuiz.value = false
|
||||
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
||||
if (block.type === 'quiz') hasQuiz.value = true
|
||||
if (block.type === 'quiz') {
|
||||
hasQuiz.value = true
|
||||
}
|
||||
})
|
||||
|
||||
if (
|
||||
@@ -507,8 +703,11 @@ const checkIfDiscussionsAllowed = () => {
|
||||
(lesson.data?.membership ||
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor)
|
||||
)
|
||||
) {
|
||||
allowDiscussions.value = true
|
||||
} else {
|
||||
allowDiscussions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const allowEdit = () => {
|
||||
@@ -548,13 +747,31 @@ const enrollStudent = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const toggleInlineMenu = async () => {
|
||||
showInlineMenu.value = false
|
||||
await nextTick()
|
||||
let selection = window.getSelection()
|
||||
if (selection.toString()) {
|
||||
showInlineMenu.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const canSeeStats = () => {
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const showVideoStats = () => {
|
||||
showStatsDialog.value = true
|
||||
}
|
||||
|
||||
const canGoZen = () => {
|
||||
if (
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor ||
|
||||
user.data?.is_evaluator
|
||||
)
|
||||
return false
|
||||
return true
|
||||
if (lesson.data?.membership) return true
|
||||
return false
|
||||
}
|
||||
@@ -590,6 +807,38 @@ const scrollDiscussionsIntoView = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const updateNotes = () => {
|
||||
notes.update({
|
||||
filters: {
|
||||
lesson: lesson.data?.name,
|
||||
member: user.data?.name,
|
||||
},
|
||||
})
|
||||
notes.reload()
|
||||
}
|
||||
|
||||
watch(allowDiscussions, () => {
|
||||
if (allowDiscussions.value) {
|
||||
tabs.value = [
|
||||
{
|
||||
label: __('Notes'),
|
||||
value: 'Notes',
|
||||
},
|
||||
{
|
||||
label: __('Community'),
|
||||
value: 'Community',
|
||||
},
|
||||
]
|
||||
} else {
|
||||
tabs.value = [
|
||||
{
|
||||
label: __('Notes'),
|
||||
value: 'Notes',
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ const addInstructorNotes = (data) => {
|
||||
const enableAutoSave = () => {
|
||||
autoSaveInterval = setInterval(() => {
|
||||
saveLesson({ showSuccessMessage: false })
|
||||
}, 5000)
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
@@ -385,8 +385,10 @@ const saveLesson = (e) => {
|
||||
showSuccessMessage = true
|
||||
}
|
||||
editor.value.save().then((outputData) => {
|
||||
outputData = removeEmptyBlocks(outputData)
|
||||
lesson.content = JSON.stringify(outputData)
|
||||
instructorEditor.value.save().then((outputData) => {
|
||||
outputData = removeEmptyBlocks(outputData)
|
||||
lesson.instructor_content = JSON.stringify(outputData)
|
||||
if (lessonDetails.data?.lesson) {
|
||||
editCurrentLesson()
|
||||
@@ -397,6 +399,14 @@ const saveLesson = (e) => {
|
||||
})
|
||||
}
|
||||
|
||||
const removeEmptyBlocks = (outputData) => {
|
||||
let blocks = outputData.blocks.filter((block) => {
|
||||
return Object.keys(block.data).length > 0 || block.type == 'paragraph'
|
||||
})
|
||||
outputData.blocks = blocks
|
||||
return outputData
|
||||
}
|
||||
|
||||
const createNewLesson = () => {
|
||||
newLessonResource.submit(
|
||||
{},
|
||||
@@ -653,6 +663,57 @@ iframe {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ce-popover__container {
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.cdx-search-field {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cdx-search-field__input {
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cdx-search-field__input::before {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cdx-search-field__input:focus {
|
||||
--tw-ring-color: theme('colors.gray.100');
|
||||
}
|
||||
|
||||
.ce-popover-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ce-popover-item__icon svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.ce-popover--opened > .ce-popover__container {
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.cdx-search-field__icon svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.cdx-search-field__icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.cdx-block.embed-tool {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:root {
|
||||
--plyr-range-fill-background: white;
|
||||
--plyr-video-control-background-hover: transparent;
|
||||
|
||||
@@ -72,7 +72,7 @@ const persona = reactive({
|
||||
const submitPersona = () => {
|
||||
let responses = {
|
||||
site: user.data?.sitename,
|
||||
no_of_students: persona.noOfStudents,
|
||||
role: persona.role,
|
||||
use_case: persona.useCase,
|
||||
}
|
||||
call('lms.lms.api.capture_user_persona', {
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
v-model="activeTab"
|
||||
/>
|
||||
</div>
|
||||
<router-view :profile="profile" />
|
||||
<router-view :profile="profile" :key="profile.data?.name" />
|
||||
</div>
|
||||
</div>
|
||||
<EditProfile
|
||||
|
||||
@@ -77,7 +77,7 @@ const evaluations = createListResource({
|
||||
],
|
||||
auto: true,
|
||||
orderBy: 'creation desc',
|
||||
limit: 100,
|
||||
pageLength: 500,
|
||||
cache: ['schedule', user.data?.name],
|
||||
transform(data) {
|
||||
return data.map((d) => {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, createResource, toast } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import { CircleAlert } from 'lucide-vue-next'
|
||||
|
||||
@@ -66,10 +66,9 @@ const roles = createResource({
|
||||
url: 'lms.lms.utils.get_roles',
|
||||
makeParams(values) {
|
||||
return {
|
||||
name: props.profile.data?.name,
|
||||
name: values.member,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
let roles = [
|
||||
'moderator',
|
||||
@@ -83,6 +82,16 @@ const roles = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.profile,
|
||||
(newValue) => {
|
||||
roles.reload({
|
||||
member: newValue.data?.name,
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const updateRole = createResource({
|
||||
url: 'lms.lms.api.save_role',
|
||||
makeParams(values) {
|
||||
@@ -97,7 +106,10 @@ const updateRole = createResource({
|
||||
const changeRole = (role) => {
|
||||
updateRole.submit(
|
||||
{
|
||||
role: convertToTitleCase(role.split('_').join(' ')),
|
||||
role:
|
||||
role == 'lms_student'
|
||||
? 'LMS Student'
|
||||
: convertToTitleCase(role.split('_').join(' ')),
|
||||
value: eval(role).value,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '5xl' }">
|
||||
<template #body-title>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{
|
||||
props.exerciseID === 'new'
|
||||
? __('Create Programming Exercise')
|
||||
: __('Edit Programming Exercise')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="exercise.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="exercise.language"
|
||||
:label="__('Language')"
|
||||
type="select"
|
||||
:options="languageOptions"
|
||||
:required="true"
|
||||
/>
|
||||
<ChildTable
|
||||
v-model="exercise.test_cases"
|
||||
:label="__('Test Cases')"
|
||||
:columns="testCaseColumns"
|
||||
:required="true"
|
||||
:addable="true"
|
||||
:deletable="true"
|
||||
:editable="true"
|
||||
:placeholder="__('Add Test Case')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __('Problem Statement') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="exercise.problem_statement"
|
||||
@change="(val: string) => (exercise.problem_statement = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[21rem] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="flex justify-end space-x-2 group">
|
||||
<Button
|
||||
v-if="exerciseID != 'new'"
|
||||
@click="deleteExercise(close)"
|
||||
variant="outline"
|
||||
theme="red"
|
||||
class="invisible group-hover:visible"
|
||||
>
|
||||
<template #prefix>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Delete') }}
|
||||
</Button>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: {
|
||||
exerciseID: props.exerciseID,
|
||||
submissionID: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<Play class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Test this Exercise') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'ProgrammingExerciseSubmissions',
|
||||
query: {
|
||||
exercise: props.exerciseID,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<ClipboardList class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Check Submission') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button variant="solid" @click="saveExercise(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
createListResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
ProgrammingExercise,
|
||||
ProgrammingExercises,
|
||||
TestCase,
|
||||
} from '@/types/programming-exercise'
|
||||
import ChildTable from '@/components/Controls/ChildTable.vue'
|
||||
import { ClipboardList, Play, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const show = defineModel()
|
||||
const exercises = defineModel<ProgrammingExercises>('exercises')
|
||||
|
||||
const exercise = ref<ProgrammingExercise>({
|
||||
title: '',
|
||||
language: 'Python',
|
||||
problem_statement: '',
|
||||
test_cases: [],
|
||||
})
|
||||
|
||||
const languageOptions = [
|
||||
{ label: 'Python', value: 'Python' },
|
||||
{ label: 'JavaScript', value: 'JavaScript' },
|
||||
]
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
exerciseID: string
|
||||
}>(),
|
||||
{
|
||||
exerciseID: 'new',
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.exerciseID,
|
||||
() => {
|
||||
setExerciseData()
|
||||
fetchTestCases()
|
||||
}
|
||||
)
|
||||
|
||||
const setExerciseData = () => {
|
||||
let isNew = true
|
||||
exercises.value?.data.forEach((ex: ProgrammingExercise) => {
|
||||
if (ex.name === props.exerciseID) {
|
||||
isNew = false
|
||||
exercise.value = { ...ex }
|
||||
}
|
||||
})
|
||||
|
||||
if (isNew) {
|
||||
exercise.value = {
|
||||
title: '',
|
||||
language: 'Python',
|
||||
problem_statement: '',
|
||||
test_cases: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testCases = createListResource({
|
||||
doctype: 'LMS Test Case',
|
||||
fields: ['input', 'expected_output', 'name'],
|
||||
cache: ['testCases', props.exerciseID],
|
||||
parent: 'LMS Programming Exercise',
|
||||
onSuccess(data: TestCase[]) {
|
||||
exercise.value.test_cases = data
|
||||
},
|
||||
})
|
||||
|
||||
const fetchTestCases = () => {
|
||||
testCases.update({
|
||||
filters: {
|
||||
parent: props.exerciseID,
|
||||
parenttype: 'LMS Programming Exercise',
|
||||
parentfield: 'test_cases',
|
||||
},
|
||||
})
|
||||
testCases.reload()
|
||||
}
|
||||
|
||||
const saveExercise = (close: () => void) => {
|
||||
if (props.exerciseID == 'new') createNewExercise(close)
|
||||
else updateExercise(close)
|
||||
}
|
||||
|
||||
const createNewExercise = (close: () => void) => {
|
||||
exercises.value?.insert.submit(
|
||||
{
|
||||
...exercise.value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
exercises.value?.reload()
|
||||
toast.success(__('Programming Exercise created successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateExercise = (close: () => void) => {
|
||||
exercises.value?.setValue.submit(
|
||||
{
|
||||
name: props.exerciseID,
|
||||
...exercise.value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
exercises.value?.reload()
|
||||
toast.success(__('Programming Exercise updated successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const testCaseColumns = computed(() => {
|
||||
return ['Input', 'Expected Output']
|
||||
})
|
||||
|
||||
const deleteExercise = (close: () => void) => {
|
||||
if (props.exerciseID == 'new') return
|
||||
exercises.value?.delete.submit(props.exerciseID, {
|
||||
onSuccess() {
|
||||
toast.success(__('Programming Exercise deleted successfully'))
|
||||
close()
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a programming exercise to your lesson'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: () => {
|
||||
saveExercise()
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<Link
|
||||
v-model="exercise"
|
||||
doctype="LMS Programming Exercise"
|
||||
:label="__('Select a Programming Exercise')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog } from 'frappe-ui'
|
||||
import { onMounted, nextTick, ref } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = ref(false)
|
||||
const exercise = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
onSave: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
show.value = true
|
||||
})
|
||||
|
||||
const saveExercise = () => {
|
||||
props.onSave(exercise.value)
|
||||
show.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<header
|
||||
v-if="!fromLesson"
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div
|
||||
v-if="falconError"
|
||||
class="flex items-center justify-between p-3 text-sm bg-surface-amber-1 text-ink-amber-3"
|
||||
>
|
||||
<span>
|
||||
{{ falconError }}
|
||||
</span>
|
||||
<Button v-if="user.data?.is_moderator" @click="openSettings('General')">
|
||||
<template #prefix>
|
||||
<Settings class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Settings') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 h-[calc(100vh_-_3rem)]">
|
||||
<div class="border-r py-5 px-8 h-full">
|
||||
<div class="font-semibold mb-2">
|
||||
{{ __('Problem Statement') }}
|
||||
</div>
|
||||
<div
|
||||
v-html="exercise.doc?.problem_statement"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between p-2 bg-surface-gray-2">
|
||||
<div class="font-semibold">
|
||||
{{ exercise.doc?.language }}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
v-if="submission.doc?.status"
|
||||
:theme="submission.doc.status == 'Passed' ? 'green' : 'red'"
|
||||
>
|
||||
{{ submission.doc.status }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-if="
|
||||
!falconError &&
|
||||
(submissionID == 'new' ||
|
||||
user.data?.name == submission.doc?.owner)
|
||||
"
|
||||
variant="solid"
|
||||
@click="submitCode"
|
||||
>
|
||||
<template #prefix>
|
||||
<Play class="size-3" />
|
||||
</template>
|
||||
{{ __('Run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4 pt-5 border-b">
|
||||
<Code
|
||||
v-model="code"
|
||||
:language="exercise.doc?.language.toLowerCase()"
|
||||
height="400px"
|
||||
maxHeight="1000px"
|
||||
/>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<span v-if="error" class="text-xs text-ink-gray-5 px-1">
|
||||
{{ __('Compiler Message') }}:
|
||||
</span>
|
||||
<textarea
|
||||
v-if="error"
|
||||
v-model="errorMessage"
|
||||
class="font-mono text-ink-red-3 bg-surface-gray-1 border-none text-sm h-32 leading-6"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<!-- <textarea v-else v-model="output" class="bg-surface-gray-1 border-none text-sm h-28 leading-6" readonly /> -->
|
||||
</div>
|
||||
|
||||
<div ref="testCaseSection" class="p-5">
|
||||
<span class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Test Cases') }}
|
||||
</span>
|
||||
<div v-if="testCases.length" class="divide-y mt-5">
|
||||
<div
|
||||
v-for="(testCase, index) in testCases"
|
||||
:key="testCase.input"
|
||||
class="py-3"
|
||||
>
|
||||
<div class="flex items-center mb-3">
|
||||
<span class=""> {{ __('Test {0}').format(index + 1) }} - </span>
|
||||
<span
|
||||
class="font-semibold ml-2 mr-1"
|
||||
:class="
|
||||
testCase.status === 'Passed'
|
||||
? 'text-ink-green-3'
|
||||
: 'text-ink-red-3'
|
||||
"
|
||||
>
|
||||
{{ testCase.status }}
|
||||
</span>
|
||||
<!-- <span v-if="testCase.status === 'Passed'">
|
||||
<Check class="size-4 text-ink-green-3" />
|
||||
</span>
|
||||
<span v-else>
|
||||
<X class="size-4 text-ink-red-3" />
|
||||
</span> -->
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-[60%]">
|
||||
<div v-if="testCase.input" class="space-y-2">
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Input') }}
|
||||
</div>
|
||||
<div>{{ testCase.input }}</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Your Output') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ testCase.output }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Expected Output') }}
|
||||
</div>
|
||||
<div>{{ testCase.expected_output }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-ink-gray-6 mt-4">
|
||||
{{ __('Please run the code to execute the test cases.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createDocumentResource,
|
||||
toast,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Play, X, Check, Settings } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { openSettings } from '@/utils'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const code = ref<string | null>('')
|
||||
const output = ref<string | null>(null)
|
||||
const error = ref<boolean | null>(null)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const testCaseSection = ref<HTMLElement | null>(null)
|
||||
const testCases = ref<TestCase[]>([])
|
||||
const boilerplate = ref<string>('')
|
||||
const { brand, livecodeURL } = sessionStore()
|
||||
const router = useRouter()
|
||||
const fromLesson = ref(false)
|
||||
const falconURL = ref<string>('https://falcon.frappe.io/')
|
||||
const falconError = ref<string | null>(null)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
exerciseID: string
|
||||
submissionID?: string
|
||||
}>(),
|
||||
{
|
||||
submissionID: 'new',
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadFalcon()
|
||||
checkIfUserIsPermitted()
|
||||
checkIfInLesson()
|
||||
fetchSubmission()
|
||||
})
|
||||
|
||||
const checkIfInLesson = () => {
|
||||
if (new URLSearchParams(window.location.search).get('fromLesson')) {
|
||||
fromLesson.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSubmission = (name: string = '') => {
|
||||
if (name) {
|
||||
submission.name = name
|
||||
submission.reload()
|
||||
} else if (props.submissionID != 'new') {
|
||||
submission.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const exercise = createDocumentResource({
|
||||
doctype: 'LMS Programming Exercise',
|
||||
name: props.exerciseID,
|
||||
cache: ['programmingExercise', props.exerciseID],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const submission = createDocumentResource({
|
||||
doctype: 'LMS Programming Exercise Submission',
|
||||
name: props.submissionID,
|
||||
auto: false,
|
||||
onError(error: any) {
|
||||
if (error.messages?.[0].includes('not found')) {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: { exerciseID: props.exerciseID, submissionID: 'new' },
|
||||
})
|
||||
} else {
|
||||
toast.error(__(error.messages?.[0] || error))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(exercise, () => {
|
||||
updateCode()
|
||||
})
|
||||
|
||||
const updateCode = (submissionCode = '') => {
|
||||
updateBoilerPlate()
|
||||
if (!code.value?.includes(boilerplate.value)) {
|
||||
code.value = `${boilerplate.value}${code.value}`
|
||||
}
|
||||
if (submissionCode && !code.value?.includes(submissionCode)) {
|
||||
code.value = `${code.value}${submissionCode}`
|
||||
} else if (!submissionCode && !code.value) {
|
||||
code.value = boilerplate.value
|
||||
}
|
||||
}
|
||||
|
||||
const updateBoilerPlate = () => {
|
||||
if (exercise.doc?.language == 'Python') {
|
||||
boilerplate.value = `with open("stdin", "r") as f:\n data = f.read()\n\ninputs = data.split() if len(data) else []\n\n# inputs is a list of strings\n# write your code below\n\n`
|
||||
} else if (exercise.doc?.language == 'JavaScript') {
|
||||
boilerplate.value = `const fs = require('fs');\n\nlet input = fs.readFileSync('/app/stdin', 'utf8').trim();\nconst inputs = input.split("\\n");\n// inputs is an array of strings\n// write your code below\n`
|
||||
}
|
||||
}
|
||||
|
||||
const checkIfUserIsPermitted = (doc: any = null) => {
|
||||
if (!user.data) {
|
||||
window.location.href = `/login?redirect-to=/lms/programming-exercises/${props.exerciseID}/submission/${props.submissionID}`
|
||||
}
|
||||
|
||||
if (!doc) return
|
||||
if (
|
||||
doc.owner != user.data?.name &&
|
||||
!user.data?.is_instructor &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data.is_evaluator
|
||||
) {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: { exerciseID: props.exerciseID, submissionID: 'new' },
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const updateTestCases = (doc: any) => {
|
||||
if (testCases.value.length === 0) {
|
||||
testCases.value = doc.test_cases || []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => submission.doc,
|
||||
(doc) => {
|
||||
if (doc) {
|
||||
checkIfUserIsPermitted(doc)
|
||||
updateTestCases(doc)
|
||||
updateCode(doc.code)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const loadFalcon = () => {
|
||||
if (livecodeURL.data) {
|
||||
falconURL.value = livecodeURL.data
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = `${falconURL.value}static/livecode.js`
|
||||
script.onload = resolve
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
const submitCode = async () => {
|
||||
await runCode()
|
||||
createSubmission()
|
||||
}
|
||||
|
||||
const runCode = async () => {
|
||||
if (!exercise.doc?.test_cases?.length) return
|
||||
|
||||
testCases.value = []
|
||||
if (testCaseSection.value) {
|
||||
testCaseSection.value.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
for (const test_case of exercise.doc.test_cases) {
|
||||
let result = await execute(test_case.input)
|
||||
if (error.value) {
|
||||
errorMessage.value = result
|
||||
break
|
||||
} else {
|
||||
output.value = result
|
||||
}
|
||||
let status =
|
||||
result.trim() === test_case.expected_output.trim() ? 'Passed' : 'Failed'
|
||||
testCases.value.push({
|
||||
input: test_case.input,
|
||||
output: result,
|
||||
expected_output: test_case.expected_output,
|
||||
status: status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const createSubmission = () => {
|
||||
if (!testCases.value.length) return
|
||||
let codeToSave = code.value?.replace(boilerplate.value, '') || ''
|
||||
|
||||
call('lms.lms.api.create_programming_exercise_submission', {
|
||||
exercise: props.exerciseID,
|
||||
submission: props.submissionID,
|
||||
code: codeToSave,
|
||||
test_cases: testCases.value,
|
||||
})
|
||||
.then((data: any) => {
|
||||
if (props.submissionID == 'new') {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: { exerciseID: props.exerciseID, submissionID: data },
|
||||
})
|
||||
fetchSubmission(data)
|
||||
} else {
|
||||
fetchSubmission(props.submissionID)
|
||||
}
|
||||
toast.success(__('Submission saved!'))
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Error creating submission:', error)
|
||||
toast.error(
|
||||
__('Failed to submit. Please try again. {0}').format({ error })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const execute = (stdin = ''): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let outputChunks: string[] = []
|
||||
let hasExited = false
|
||||
let hasError = false
|
||||
|
||||
let session = new LiveCodeSession({
|
||||
base_url: falconURL.value,
|
||||
runtime: exercise.doc?.language.toLowerCase() || 'python',
|
||||
code: code.value,
|
||||
files: [{ filename: 'stdin', contents: stdin }],
|
||||
onMessage: (msg: any) => {
|
||||
console.log('msg', msg)
|
||||
|
||||
if (msg.msgtype === 'write' && msg.file === 'stdout') {
|
||||
outputChunks.push(msg.data)
|
||||
}
|
||||
|
||||
if (msg.msgtype === 'write' && msg.file === 'stderr') {
|
||||
hasError = true
|
||||
errorMessage.value = msg.data
|
||||
}
|
||||
|
||||
if (msg.msgtype === 'exitstatus') {
|
||||
hasExited = true
|
||||
if (msg.exitstatus !== 0) {
|
||||
error.value = true
|
||||
} else {
|
||||
error.value = false
|
||||
}
|
||||
resolve(outputChunks.join('').trim())
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasExited) {
|
||||
error.value = true
|
||||
errorMessage.value = 'Execution timed out.'
|
||||
reject('Execution timed out.')
|
||||
}
|
||||
}, 20000)
|
||||
})
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Programming Exercise Submissions'),
|
||||
route: { name: 'ProgrammingExerciseSubmissions' },
|
||||
},
|
||||
{ label: exercise.doc?.title },
|
||||
]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Programming Exercise Submission'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.ProseMirror pre {
|
||||
background: theme('colors.gray.200');
|
||||
color: theme('colors.gray.900');
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between space-x-32 mb-5">
|
||||
<div class="text-xl font-semibold text-ink-gray-7">
|
||||
{{
|
||||
submissions.data?.length
|
||||
? __('{0} Submissions').format(submissions.data.length)
|
||||
: __('No Submissions')
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="submissions.data?.length"
|
||||
class="grid grid-cols-3 gap-5 flex-1"
|
||||
>
|
||||
<Link
|
||||
doctype="LMS Programming Exercise"
|
||||
v-model="filters.exercise"
|
||||
:placeholder="__('Filter by Exercise')"
|
||||
/>
|
||||
<Link
|
||||
doctype="User"
|
||||
v-model="filters.member"
|
||||
:placeholder="__('Filter by Member')"
|
||||
:readonly="isStudent"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="filters.status"
|
||||
type="select"
|
||||
:options="[
|
||||
{ label: __(''), value: '' },
|
||||
{ label: __('Passed'), value: 'Passed' },
|
||||
{ label: __('Failed'), value: 'Failed' },
|
||||
]"
|
||||
:placeholder="__('Filter by Status')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="submissions.loading || submissions.data?.length"
|
||||
:columns="submissionColumns"
|
||||
:rows="submissions.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: true,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in submissionColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<router-link
|
||||
v-for="row in submissions.data"
|
||||
:to="{
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: {
|
||||
exerciseID: row.exercise,
|
||||
submissionID: row.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="column.key == 'status'">
|
||||
<Badge
|
||||
:theme="row[column.key] === 'Passed' ? 'green' : 'red'"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'modified'"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteExercises(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<EmptyState v-else type="Programming Exercise Submissions" />
|
||||
<div
|
||||
v-if="submissions.data && submissions.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
>
|
||||
<Button @click="submissions.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import type {
|
||||
ProgrammingExerciseSubmission,
|
||||
Filters,
|
||||
} from '@/pages/ProgrammingExercises/types'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const dayjs = inject('$dayjs') as any
|
||||
const user = inject('$user') as any
|
||||
const filterFields = ['exercise', 'member', 'status']
|
||||
const filters = ref<Filters>({
|
||||
exercise: '',
|
||||
member: '',
|
||||
status: '',
|
||||
})
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
setFiltersFromRoute()
|
||||
fetchBasedOnRole()
|
||||
})
|
||||
|
||||
const setFiltersFromRoute = () => {
|
||||
filterFields.forEach((field) => {
|
||||
if (router.currentRoute.value.query[field]) {
|
||||
filters.value[field as keyof Filters] = router.currentRoute.value.query[
|
||||
field
|
||||
] as string
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fetchBasedOnRole = () => {
|
||||
if (isStudent.value) {
|
||||
filters.value['member'] = user.data?.name
|
||||
} else {
|
||||
submissions.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const submissions = createListResource({
|
||||
doctype: 'LMS Programming Exercise Submission',
|
||||
fields: [
|
||||
'name',
|
||||
'exercise',
|
||||
'exercise_title',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'status',
|
||||
'modified',
|
||||
],
|
||||
orderBy: 'modified desc',
|
||||
transform(data: ProgrammingExercise[]) {
|
||||
return data.map((submission: ProgrammingExerciseSubmission) => {
|
||||
return {
|
||||
...submission,
|
||||
modified: dayjs(submission.modified).fromNow(),
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
watch(filters.value, () => {
|
||||
let filtersToApply: Record<string, any> = {}
|
||||
filterFields.forEach((field) => {
|
||||
if (filters.value[field as keyof Filters]) {
|
||||
filtersToApply[field] = filters.value[field as keyof Filters]
|
||||
router.push({
|
||||
query: {
|
||||
...router.currentRoute.value.query,
|
||||
[field]: filters.value[field as keyof Filters],
|
||||
},
|
||||
})
|
||||
} else {
|
||||
delete filtersToApply[field]
|
||||
const query = { ...router.currentRoute.value.query }
|
||||
delete query[field]
|
||||
router.push({
|
||||
query,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
submissions.update({
|
||||
filters: {
|
||||
...filtersToApply,
|
||||
},
|
||||
})
|
||||
submissions.reload()
|
||||
})
|
||||
|
||||
const deleteExercises = (selections: Set<string>, unselectAll: () => void) => {
|
||||
Array.from(selections).forEach(async (submission: string) => {
|
||||
await submissions.delete.submit(submission)
|
||||
})
|
||||
unselectAll()
|
||||
toast.success(__('Submissions deleted successfully'))
|
||||
}
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return (
|
||||
!user.data?.is_instructor &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_evaluator
|
||||
)
|
||||
})
|
||||
|
||||
const submissionColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
width: '30%',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Exercise'),
|
||||
key: 'exercise_title',
|
||||
width: '30%',
|
||||
icon: 'code',
|
||||
},
|
||||
{
|
||||
label: __('Status'),
|
||||
key: 'status',
|
||||
width: '20%',
|
||||
icon: 'check-circle',
|
||||
},
|
||||
{
|
||||
label: __('Modified'),
|
||||
key: 'modified',
|
||||
width: '15%',
|
||||
icon: 'clock',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Programming Exercise Submissions'),
|
||||
route: {
|
||||
name: 'ProgrammingExerciseSubmissions',
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Programming Exercises'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
171
frontend/src/pages/ProgrammingExercises/ProgrammingExercises.vue
Normal file
171
frontend/src/pages/ProgrammingExercises/ProgrammingExercises.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div class="space-x-2">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'ProgrammingExerciseSubmissions',
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<ClipboardList class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Check All Submissions') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
v-if="!readOnlyMode"
|
||||
variant="solid"
|
||||
@click="
|
||||
() => {
|
||||
exerciseID = 'new'
|
||||
showForm = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="md:w-4/5 md:mx-auto p-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div v-if="exerciseCount" class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('{0} Exercises').format(exerciseCount) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="exercises.data?.length || exerciseCount > 0"
|
||||
class="grid grid-cols-2 gap-5"
|
||||
>
|
||||
<!-- <FormControl
|
||||
v-model="titleFilter"
|
||||
:placeholder="__('Search by title')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="typeFilter"
|
||||
type="select"
|
||||
:options="assignmentTypes"
|
||||
:placeholder="__('Type')"
|
||||
/> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="exercises.data?.length"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div
|
||||
v-for="exercise in exercises.data"
|
||||
:key="exercise.name"
|
||||
@click="
|
||||
() => {
|
||||
exerciseID = exercise.name
|
||||
showForm = true
|
||||
}
|
||||
"
|
||||
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3 space-y-2 cursor-pointer"
|
||||
>
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ exercise.title }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ exercise.language }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else type="Programming Exercises" />
|
||||
<div
|
||||
v-if="exercises.data && exercises.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
>
|
||||
<Button @click="exercises.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ProgrammingExerciseForm
|
||||
v-model="showForm"
|
||||
:exerciseID="exerciseID"
|
||||
v-model:exercises="exercises"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { ClipboardList, Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProgrammingExerciseForm from '@/pages/ProgrammingExercises/ProgrammingExerciseForm.vue'
|
||||
|
||||
const exerciseCount = ref<number>(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const { brand } = sessionStore()
|
||||
const showForm = ref<boolean>(false)
|
||||
const exerciseID = ref<string | null>('new')
|
||||
const user = inject<any>('$user')
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
validatePermissions()
|
||||
getExerciseCount()
|
||||
})
|
||||
|
||||
const validatePermissions = () => {
|
||||
if (
|
||||
!user.data?.is_instructor &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_evaluator
|
||||
) {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmissions',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getExerciseCount = () => {
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Programming Exercise',
|
||||
})
|
||||
.then((count: number) => {
|
||||
exerciseCount.value = count
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Error fetching exercise count:', error)
|
||||
})
|
||||
}
|
||||
|
||||
const exercises = createListResource({
|
||||
doctype: 'LMS Programming Exercise',
|
||||
cache: ['programmingExercises'],
|
||||
fields: ['name', 'title', 'language', 'problem_statement'],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Programming Exercises'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Programming Exercises'),
|
||||
route: { name: 'ProgrammingExercises' },
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
47
frontend/src/pages/ProgrammingExercises/types.ts
Normal file
47
frontend/src/pages/ProgrammingExercises/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
interface ProgrammingExercise {
|
||||
name: string;
|
||||
title: string;
|
||||
language: 'Python' | 'JavaScript';
|
||||
test_cases_count: number;
|
||||
problem_statement: string;
|
||||
test_cases: [TestCase];
|
||||
}
|
||||
|
||||
interface TestCase {
|
||||
name: string;
|
||||
input: string;
|
||||
expected_output: string;
|
||||
output: string;
|
||||
status: 'Passed' | 'Failed';
|
||||
}
|
||||
|
||||
type Filters = {
|
||||
exercise?: string,
|
||||
member?: string,
|
||||
status?: string
|
||||
}
|
||||
|
||||
type ProgrammingExercises = {
|
||||
data: ProgrammingExercise[]
|
||||
reload: () => void
|
||||
hasNextPage: boolean
|
||||
next: () => void
|
||||
setValue: {
|
||||
submit: (
|
||||
data: ProgrammingExercise,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void
|
||||
}
|
||||
insert: {
|
||||
submit: (
|
||||
data: ProgrammingExercise,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void
|
||||
}
|
||||
delete: {
|
||||
submit: (
|
||||
name: string,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void
|
||||
}
|
||||
}
|
||||
@@ -4,30 +4,39 @@
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div v-if="!readOnlyMode" class="space-x-2">
|
||||
<Badge v-if="quizDetails.isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
v-if="quizDetails.doc?.name"
|
||||
:to="{
|
||||
name: 'QuizPage',
|
||||
params: {
|
||||
quizID: quizDetails.data.name,
|
||||
quizID: quizDetails.doc.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Open') }}
|
||||
<template #prefix>
|
||||
<ListChecks class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Test Quiz') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
v-if="quizDetails.doc?.name"
|
||||
:to="{
|
||||
name: 'QuizSubmissionList',
|
||||
params: {
|
||||
quizID: quizDetails.data.name,
|
||||
quizID: quizDetails.doc.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Submission List') }}
|
||||
<template #prefix>
|
||||
<ClipboardList class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Check Submissions') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button variant="solid" @click="submitQuiz()">
|
||||
@@ -35,144 +44,152 @@
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-3/4 mx-auto py-5">
|
||||
<!-- Details -->
|
||||
<div class="mb-8">
|
||||
<div class="font-semibold text-ink-gray-9 mb-4">
|
||||
<div v-if="quizDetails.doc" class="py-5">
|
||||
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="quiz.title"
|
||||
:label="
|
||||
quizDetails.data?.name
|
||||
? __('Title')
|
||||
: __('Enter a title and save the quiz to proceed')
|
||||
"
|
||||
:required="true"
|
||||
/>
|
||||
<div v-if="quizDetails.data?.name">
|
||||
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="quiz.max_attempts"
|
||||
v-model="quizDetails.doc.max_attempts"
|
||||
:label="__('Maximum Attempts')"
|
||||
/>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="quiz.duration"
|
||||
v-model="quizDetails.doc.duration"
|
||||
:label="__('Duration (in minutes)')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="quiz.total_marks"
|
||||
v-model="quizDetails.doc.total_marks"
|
||||
:label="__('Total Marks')"
|
||||
disabled
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.passing_percentage"
|
||||
v-model="quizDetails.doc.passing_percentage"
|
||||
:label="__('Passing Percentage')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="mb-8">
|
||||
<div class="font-semibold text-ink-gray-9 mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 my-4">
|
||||
<FormControl
|
||||
v-model="quiz.show_answers"
|
||||
type="checkbox"
|
||||
:label="__('Show Answers')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.show_submission_history"
|
||||
type="checkbox"
|
||||
:label="__('Show Submission History')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="font-semibold text-ink-gray-9 mb-4">
|
||||
{{ __('Shuffle Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3">
|
||||
<FormControl
|
||||
v-model="quiz.shuffle_questions"
|
||||
type="checkbox"
|
||||
:label="__('Shuffle Questions')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="quiz.shuffle_questions"
|
||||
v-model="quiz.limit_questions_to"
|
||||
:label="__('Limit Questions To')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Questions -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="font-semibold text-ink-gray-9">
|
||||
{{ __('Questions') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openQuestionModal()">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New Question') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ListView
|
||||
:columns="questionColumns"
|
||||
:rows="quiz.questions"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in questionColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-slot="{ idx, column, item }"
|
||||
v-for="row in quiz.questions"
|
||||
@click="openQuestionModal(row)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<div
|
||||
v-if="column.key == 'question_detail'"
|
||||
class="text-xs truncate h-4"
|
||||
v-html="item"
|
||||
></div>
|
||||
<div v-else class="text-xs">
|
||||
{{ item }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteQuestions(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
<div class="flex flex-col space-y-10">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.show_answers"
|
||||
type="checkbox"
|
||||
:label="__('Show Answers')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.show_submission_history"
|
||||
type="checkbox"
|
||||
:label="__('Show Submission History')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.shuffle_questions"
|
||||
type="checkbox"
|
||||
:label="__('Shuffle Questions')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="quizDetails.doc.shuffle_questions"
|
||||
v-model="quizDetails.doc.limit_questions_to"
|
||||
:label="__('Limit Questions To')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.enable_negative_marking"
|
||||
type="checkbox"
|
||||
:label="__('Enable Negative Marking')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="quizDetails.doc.enable_negative_marking"
|
||||
v-model="quizDetails.doc.marks_to_cut"
|
||||
:label="__('Marks to Cut')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-20 pb-5 space-y-5 mb-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Questions') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openQuestionModal()">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New Question') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="questions.length"
|
||||
:columns="questionColumns"
|
||||
:rows="questions"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in questionColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-slot="{ idx, column, item }"
|
||||
v-for="row in questions"
|
||||
@click="openQuestionModal(row)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<div
|
||||
v-if="column.key == 'question_detail'"
|
||||
class="text-xs truncate h-4"
|
||||
v-html="item"
|
||||
></div>
|
||||
<div v-else class="text-xs">
|
||||
{{ item }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteQuestions(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<div v-else class="text-ink-gray-6 text-sm">
|
||||
{{ __('No questions added yet') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Question
|
||||
v-model="showQuestionModal"
|
||||
:questionDetail="currentQuestion"
|
||||
@@ -199,6 +216,8 @@ import {
|
||||
Button,
|
||||
usePageMeta,
|
||||
toast,
|
||||
createDocumentResource,
|
||||
Badge,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
computed,
|
||||
@@ -210,8 +229,7 @@ import {
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Question from '@/components/Modals/Question.vue'
|
||||
|
||||
@@ -233,18 +251,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const quiz = reactive({
|
||||
title: '',
|
||||
total_marks: 0,
|
||||
passing_percentage: 0,
|
||||
max_attempts: 0,
|
||||
duration: 0,
|
||||
limit_questions_to: 0,
|
||||
show_answers: true,
|
||||
show_submission_history: false,
|
||||
shuffle_questions: false,
|
||||
questions: [],
|
||||
})
|
||||
const questions = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
@@ -280,86 +287,26 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const quizDetails = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return { doctype: 'LMS Quiz', name: props.quizID }
|
||||
},
|
||||
const quizDetails = createDocumentResource({
|
||||
doctype: 'LMS Quiz',
|
||||
name: props.quizID,
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (Object.hasOwn(quiz, key)) quiz[key] = data[key]
|
||||
})
|
||||
|
||||
let checkboxes = [
|
||||
'show_answers',
|
||||
'show_submission_history',
|
||||
'shuffle_questions',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
quiz[key] = quiz[key] ? true : false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const quizCreate = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
auto: false,
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Quiz',
|
||||
...quiz,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const quizUpdate = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
auto: false,
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Quiz',
|
||||
name: values.quizID,
|
||||
fieldname: {
|
||||
total_marks: calculateTotalMarks(),
|
||||
...quiz,
|
||||
},
|
||||
onSuccess(doc) {
|
||||
if (doc.questions && doc.questions.length > 0) {
|
||||
questions.value = doc.questions.map((question) => question)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submitQuiz = () => {
|
||||
if (quizDetails.data?.name) updateQuiz()
|
||||
else createQuiz()
|
||||
}
|
||||
|
||||
const createQuiz = () => {
|
||||
quizCreate.submit(
|
||||
{},
|
||||
quizDetails.setValue.submit(
|
||||
{
|
||||
...quizDetails.doc,
|
||||
total_marks: calculateTotalMarks(),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Quiz created successfully'))
|
||||
router.push({
|
||||
name: 'QuizForm',
|
||||
params: { quizID: data.name },
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateQuiz = () => {
|
||||
quizUpdate.submit(
|
||||
{ quizID: quizDetails.data?.name },
|
||||
{
|
||||
onSuccess(data) {
|
||||
quiz.total_marks = data.total_marks
|
||||
quizDetails.doc.total_marks = data.total_marks
|
||||
toast.success(__('Quiz updated successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
@@ -371,9 +318,15 @@ const updateQuiz = () => {
|
||||
|
||||
const calculateTotalMarks = () => {
|
||||
let totalMarks = 0
|
||||
if (quiz.limit_questions_to && quiz.questions.length > 0)
|
||||
return quiz.questions[0].marks * quiz.limit_questions_to
|
||||
quiz.questions.forEach((question) => {
|
||||
if (
|
||||
quizDetails.doc?.limit_questions_to &&
|
||||
quizDetails.doc?.questions.length > 0
|
||||
)
|
||||
return (
|
||||
quizDetails.doc.questions[0].marks * quizDetails.doc.limit_questions_to
|
||||
)
|
||||
|
||||
quizDetails.doc?.questions.forEach((question) => {
|
||||
totalMarks += question.marks
|
||||
})
|
||||
return totalMarks
|
||||
@@ -448,7 +401,7 @@ const breadcrumbs = computed(() => {
|
||||
]
|
||||
|
||||
crumbs.push({
|
||||
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
|
||||
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
|
||||
route: { name: 'QuizForm', params: { quizID: props.quizID } },
|
||||
})
|
||||
return crumbs
|
||||
@@ -456,7 +409,7 @@ const breadcrumbs = computed(() => {
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
|
||||
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,37 +3,42 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<router-link
|
||||
v-if="!readOnlyMode"
|
||||
:to="{
|
||||
name: 'QuizForm',
|
||||
params: {
|
||||
quizID: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New Quiz') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button v-if="!readOnlyMode" variant="solid" @click="showForm = true">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<div v-if="quizCount" class="text-xl font-semibold text-ink-gray-7 mb-4">
|
||||
{{ __('{0} Quizzes').format(quizCount) }}
|
||||
<div class="py-5 mx-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-lg font-semibold text-ink-gray-7">
|
||||
{{
|
||||
quizzes.data?.length
|
||||
? __('{0} Quizzes').format(quizzes.data.length)
|
||||
: __('No Quizzes')
|
||||
}}
|
||||
</div>
|
||||
<FormControl v-model="search" type="text" placeholder="Search">
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="size-4 text-ink-gray-5" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="quizzes.data?.length"
|
||||
:columns="quizColumns"
|
||||
:rows="quizzes.data"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false, selectable: false }"
|
||||
:options="{ showTooltip: false, selectable: true }"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in quizColumns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
@@ -46,72 +51,176 @@
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row" />
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'show_answers'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="row[column.key]"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'modified'"
|
||||
class="text-xs text-ink-gray-5"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteQuiz(selections, unselectAll)"
|
||||
>
|
||||
<FeatherIcon name="trash-2" class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<div class="flex justify-center my-5">
|
||||
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()">
|
||||
<EmptyState v-else type="Quizzes" />
|
||||
<div v-if="quizzes.hasNextPage" class="flex justify-center my-5">
|
||||
<Button @click="quizzes.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else type="Quizzes" />
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
title: __('Create a Quiz'),
|
||||
size: 'sm',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick({ close }) {
|
||||
insertQuiz(close)
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<FormControl v-model="title" :label="__('Title')" type="text" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
Dialog,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const router = useRouter()
|
||||
const quizCount = ref(0)
|
||||
const search = ref('')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const quizFilters = ref({})
|
||||
const showForm = ref(false)
|
||||
const title = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
} else if (!user.data?.is_moderator) {
|
||||
quizFilters.value['owner'] = user.data?.name
|
||||
}
|
||||
getQuizCount()
|
||||
})
|
||||
|
||||
const quizFilter = computed(() => {
|
||||
if (user.data?.is_moderator) return {}
|
||||
return {
|
||||
owner: user.data?.name,
|
||||
}
|
||||
watch(search, () => {
|
||||
quizFilters.value['title'] = ['like', `%${search.value}%`]
|
||||
quizzes.update({
|
||||
filters: quizFilters.value,
|
||||
})
|
||||
quizzes.reload()
|
||||
})
|
||||
|
||||
const quizzes = createListResource({
|
||||
doctype: 'LMS Quiz',
|
||||
filters: quizFilter,
|
||||
fields: ['name', 'title', 'passing_percentage', 'total_marks'],
|
||||
filters: quizFilters,
|
||||
fields: [
|
||||
'name',
|
||||
'title',
|
||||
'passing_percentage',
|
||||
'total_marks',
|
||||
'show_answers',
|
||||
'max_attempts',
|
||||
'modified',
|
||||
],
|
||||
auto: true,
|
||||
cache: ['quizzes', user.data?.name],
|
||||
orderBy: 'modified desc',
|
||||
transform(data) {
|
||||
return data.map((quiz) => {
|
||||
return {
|
||||
...quiz,
|
||||
modified: dayjs(quiz.modified).fromNow(),
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const getQuizCount = () => {
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Quiz',
|
||||
}).then((data) => {
|
||||
quizCount.value = data
|
||||
const insertQuiz = (close) => {
|
||||
quizzes.insert.submit(
|
||||
{
|
||||
title: title.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Quiz created successfully'))
|
||||
close()
|
||||
title.value = ''
|
||||
router.push({
|
||||
name: 'QuizForm',
|
||||
params: {
|
||||
quizID: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(__('Error creating quiz: {0}', error.message))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteQuiz = (selections, unselectAll) => {
|
||||
Array.from(selections).forEach(async (quizName) => {
|
||||
await quizzes.delete.submit(quizName)
|
||||
})
|
||||
unselectAll()
|
||||
toast.success(__('Quizzes deleted successfully'))
|
||||
}
|
||||
|
||||
const quizColumns = computed(() => {
|
||||
@@ -120,18 +229,42 @@ const quizColumns = computed(() => {
|
||||
label: __('Title'),
|
||||
key: 'title',
|
||||
width: 2,
|
||||
icon: 'file-text',
|
||||
},
|
||||
{
|
||||
label: __('Total Marks'),
|
||||
key: 'total_marks',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'hash',
|
||||
},
|
||||
{
|
||||
label: __('Passing Percentage'),
|
||||
key: 'passing_percentage',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'percent',
|
||||
},
|
||||
{
|
||||
label: __('Max Attempts'),
|
||||
key: 'max_attempts',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'repeat',
|
||||
},
|
||||
{
|
||||
label: __('Show Answers'),
|
||||
key: 'show_answers',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'eye',
|
||||
},
|
||||
{
|
||||
label: __('Modified'),
|
||||
key: 'modified',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'clock',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -215,6 +215,30 @@ const routes = [
|
||||
name: 'PersonaForm',
|
||||
component: () => import('@/pages/PersonaForm.vue'),
|
||||
},
|
||||
{
|
||||
path: '/programming-exercises',
|
||||
name: 'ProgrammingExercises',
|
||||
component: () =>
|
||||
import('@/pages/ProgrammingExercises/ProgrammingExercises.vue'),
|
||||
},
|
||||
{
|
||||
path: '/programming-exercises/submissions',
|
||||
name: 'ProgrammingExerciseSubmissions',
|
||||
component: () =>
|
||||
import(
|
||||
'@/pages/ProgrammingExercises/ProgrammingExerciseSubmissions.vue'
|
||||
),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/programming-exercises/:exerciseID/submission/:submissionID',
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
component: () =>
|
||||
import(
|
||||
'@/pages/ProgrammingExercises/ProgrammingExerciseSubmission.vue'
|
||||
),
|
||||
props: true,
|
||||
},
|
||||
]
|
||||
|
||||
let router = createRouter({
|
||||
|
||||
@@ -54,10 +54,14 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const sidebarSettings = createResource({
|
||||
url: 'lms.lms.api.get_sidebar_settings',
|
||||
cache: 'Sidebar Settings',
|
||||
auto: false,
|
||||
const livecodeURL = createResource({
|
||||
url: 'frappe.client.get_single_value',
|
||||
params: {
|
||||
doctype: 'LMS Settings',
|
||||
field: 'livecode_url',
|
||||
},
|
||||
cache: 'livecodeURL',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -67,6 +71,6 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
logout,
|
||||
brand,
|
||||
branding,
|
||||
sidebarSettings,
|
||||
livecodeURL,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,21 +9,38 @@ export const useSettings = defineStore('settings', () => {
|
||||
const activeTab = ref(null)
|
||||
|
||||
const learningPaths = createResource({
|
||||
url: 'lms.lms.api.is_learning_path_enabled',
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'enable_learning_paths' },
|
||||
auto: true,
|
||||
cache: ['learningPath'],
|
||||
})
|
||||
|
||||
const allowGuestAccess = createResource({
|
||||
url: 'lms.lms.api.is_guest_allowed',
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'allow_guest_access' },
|
||||
auto: true,
|
||||
cache: ['allowGuestAccess'],
|
||||
})
|
||||
|
||||
const preventSkippingVideos = createResource({
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'prevent_skipping_videos' },
|
||||
auto: true,
|
||||
cache: ['preventSkippingVideos'],
|
||||
})
|
||||
|
||||
const sidebarSettings = createResource({
|
||||
url: 'lms.lms.api.get_sidebar_settings',
|
||||
cache: 'Sidebar Settings',
|
||||
auto: false,
|
||||
})
|
||||
|
||||
return {
|
||||
isSettingsOpen,
|
||||
activeTab,
|
||||
learningPaths,
|
||||
allowGuestAccess,
|
||||
preventSkippingVideos,
|
||||
sidebarSettings,
|
||||
}
|
||||
})
|
||||
|
||||
65
frontend/src/styles/codemirror.css
Normal file
65
frontend/src/styles/codemirror.css
Normal file
@@ -0,0 +1,65 @@
|
||||
.cm-editor {
|
||||
user-select: text;
|
||||
padding: 0px !important;
|
||||
position: relative !important;
|
||||
}
|
||||
.cm-gutters {
|
||||
@apply !border-0 !bg-transparent !px-1.5 !text-xs !leading-6 !text-gray-500;
|
||||
}
|
||||
.cm-foldGutter span {
|
||||
@apply !hidden !opacity-0;
|
||||
}
|
||||
.cm-gutterElement {
|
||||
@apply !text-left;
|
||||
}
|
||||
.cm-activeLine {
|
||||
@apply !bg-transparent;
|
||||
}
|
||||
.cm-activeLineGutter {
|
||||
@apply !bg-transparent text-gray-600;
|
||||
}
|
||||
.cm-editor {
|
||||
width: 100%;
|
||||
user-select: text;
|
||||
}
|
||||
.cm-placeholder {
|
||||
@apply !leading-6 !text-gray-500;
|
||||
}
|
||||
.cm-scroller {
|
||||
@apply !font-mono !leading-6 !text-gray-600;
|
||||
}
|
||||
.cm-matchingBracket {
|
||||
font-weight: 500 !important;
|
||||
background: none !important;
|
||||
border-bottom: 1px solid #000 !important;
|
||||
outline: none !important;
|
||||
}
|
||||
.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
.cm-tooltip-autocomplete {
|
||||
@apply !rounded-lg !shadow-md !bg-surface-white !p-1.5 !border-none;
|
||||
}
|
||||
.cm-tooltip-autocomplete > ul {
|
||||
font-family: 'Inter' !important;
|
||||
}
|
||||
.cm-tooltip-autocomplete ul li[aria-selected='true'] {
|
||||
@apply !rounded !bg-gray-200/80;
|
||||
color: #000 !important;
|
||||
}
|
||||
.cm-completionLabel {
|
||||
margin-right: 1rem !important;
|
||||
}
|
||||
.cm-completionDetail {
|
||||
margin-left: auto !important;
|
||||
}
|
||||
.inline-expression .cm-content {
|
||||
padding: 0 !important;
|
||||
line-height: 26px !important;
|
||||
}
|
||||
.inline-expression .cm-placeholder {
|
||||
line-height: 26px !important;
|
||||
}
|
||||
.inline-expression .cm-gutters {
|
||||
line-height: 26px !important;
|
||||
}
|
||||
@@ -56,12 +56,18 @@ export class Assignment {
|
||||
})
|
||||
return
|
||||
}
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-surface-menu-bar mb-2'>
|
||||
<span class="font-medium">
|
||||
Assignment: ${assignment}
|
||||
</span>
|
||||
</div>`
|
||||
return
|
||||
call('frappe.client.get_value', {
|
||||
doctype: 'LMS Assignment',
|
||||
name: assignment,
|
||||
fieldname: ['title'],
|
||||
}).then((data) => {
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
|
||||
<span class="font-medium">
|
||||
Assignment: ${data.title}
|
||||
</span>
|
||||
</div>`
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
renderAssignmentModal() {
|
||||
@@ -79,7 +85,8 @@ export class Assignment {
|
||||
app.mount(this.wrapper)
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
save() {
|
||||
if (Object.keys(this.data).length === 0) return {}
|
||||
return {
|
||||
assignment: this.data.assignment,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { watch } from 'vue'
|
||||
import { call, toast } from 'frappe-ui'
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
import { theme } from '@/utils/theme'
|
||||
import { Quiz } from '@/utils/quiz'
|
||||
import { Program } from '@/utils/program'
|
||||
import { Assignment } from '@/utils/assignment'
|
||||
import { Upload } from '@/utils/upload'
|
||||
import { Markdown } from '@/utils/markdownParser'
|
||||
@@ -103,24 +104,6 @@ export function getImgDimensions(imgSrc) {
|
||||
})
|
||||
}
|
||||
|
||||
export function updateDocumentTitle(meta) {
|
||||
watch(
|
||||
() => meta,
|
||||
(meta) => {
|
||||
if (!meta.value.title) return
|
||||
if (meta.value.title && meta.value.subtitle) {
|
||||
document.title = `${meta.value.title} | ${meta.value.subtitle}`
|
||||
return
|
||||
}
|
||||
if (meta.value.title) {
|
||||
document.title = `${meta.value.title}`
|
||||
return
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function htmlToText(html) {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
@@ -135,18 +118,26 @@ export function getEditorTools() {
|
||||
placeholder: 'Header',
|
||||
},
|
||||
},
|
||||
list: {
|
||||
class: NestedList,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
defaultStyle: 'ordered',
|
||||
},
|
||||
},
|
||||
table: {
|
||||
class: Table,
|
||||
inlineToolbar: true,
|
||||
},
|
||||
quiz: Quiz,
|
||||
assignment: Assignment,
|
||||
program: Program,
|
||||
upload: Upload,
|
||||
markdown: {
|
||||
class: Markdown,
|
||||
inlineToolbar: true,
|
||||
},
|
||||
image: SimpleImage,
|
||||
table: {
|
||||
class: Table,
|
||||
inlineToolbar: true,
|
||||
},
|
||||
paragraph: {
|
||||
class: Paragraph,
|
||||
inlineToolbar: true,
|
||||
@@ -160,13 +151,6 @@ export function getEditorTools() {
|
||||
useDefaultTheme: 'dark',
|
||||
},
|
||||
},
|
||||
list: {
|
||||
class: NestedList,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
defaultStyle: 'ordered',
|
||||
},
|
||||
},
|
||||
inlineCode: {
|
||||
class: InlineCode,
|
||||
shortcut: 'CMD+SHIFT+M',
|
||||
@@ -197,6 +181,14 @@ export function getEditorTools() {
|
||||
window.innerWidth < 640 ? '15rem' : '30rem'
|
||||
};" frameborder="0" allowfullscreen></iframe>`,
|
||||
},
|
||||
bunnyStream: {
|
||||
regex: /https:\/\/(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)\/play\/([a-zA-Z0-9]+\/[a-zA-Z0-9-]+)/,
|
||||
embedUrl:
|
||||
'https://iframe.mediadelivery.net/embed/<%= remote_id %>',
|
||||
html: `<iframe style="width:100%; height: ${
|
||||
window.innerWidth < 640 ? '15rem' : '30rem'
|
||||
};" frameborder="0" allowfullscreen></iframe>`,
|
||||
},
|
||||
codepen: true,
|
||||
aparat: {
|
||||
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,
|
||||
@@ -495,11 +487,39 @@ export function singularize(word) {
|
||||
)
|
||||
}
|
||||
|
||||
export const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
||||
return __('Only image file is allowed.')
|
||||
export const validateFile = async (file, showToast = true) => {
|
||||
const error = (msg) => {
|
||||
if (showToast) toast.error(msg)
|
||||
console.error(msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return error(__('Only image file is allowed.'))
|
||||
}
|
||||
|
||||
if (file.type === 'image/svg+xml') {
|
||||
const text = await file.text()
|
||||
|
||||
const blacklist = [
|
||||
/<script[\s>]/i,
|
||||
/on\w+=["']?/i,
|
||||
/javascript:/i,
|
||||
/data:/i,
|
||||
/<iframe[\s>]/i,
|
||||
/<object[\s>]/i,
|
||||
/<embed[\s>]/i,
|
||||
/<link[\s>]/i,
|
||||
]
|
||||
|
||||
for (const pattern of blacklist) {
|
||||
if (pattern.test(text)) {
|
||||
return error(__('SVG contains potentially unsafe content.'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const escapeHTML = (text) => {
|
||||
@@ -528,45 +548,96 @@ export const canCreateCourse = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const enablePlyr = () => {
|
||||
setTimeout(() => {
|
||||
const videoElement = document.getElementsByClassName('video-player')
|
||||
if (videoElement.length === 0) return
|
||||
export const enablePlyr = async () => {
|
||||
await wait(500)
|
||||
|
||||
Array.from(videoElement).forEach((video) => {
|
||||
const src = video.getAttribute('src')
|
||||
if (src) {
|
||||
let videoID = src.split('/').pop()
|
||||
video.setAttribute('data-plyr-embed-id', videoID)
|
||||
}
|
||||
new Plyr(video, {
|
||||
youtube: {
|
||||
noCookie: true,
|
||||
},
|
||||
controls: [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'mute',
|
||||
'volume',
|
||||
'fullscreen',
|
||||
],
|
||||
})
|
||||
}, 500)
|
||||
const players = []
|
||||
const videoElements = document.getElementsByClassName('video-player')
|
||||
|
||||
if (videoElements.length === 0) return players
|
||||
|
||||
Array.from(videoElements).forEach((video) => {
|
||||
setupPlyrForVideo(video, players)
|
||||
})
|
||||
|
||||
return players
|
||||
}
|
||||
|
||||
export const openSettings = (category, close) => {
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const setupPlyrForVideo = (video, players) => {
|
||||
const src = video.getAttribute('src')
|
||||
|
||||
if (src) {
|
||||
const videoID = extractYouTubeId(src)
|
||||
video.setAttribute('data-plyr-embed-id', videoID)
|
||||
}
|
||||
|
||||
let controls = [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'mute',
|
||||
'volume',
|
||||
'fullscreen',
|
||||
]
|
||||
|
||||
const player = new Plyr(video, {
|
||||
youtube: { noCookie: true },
|
||||
controls: controls,
|
||||
listeners: {
|
||||
seek: function customSeekBehavior(e) {
|
||||
const current_time = player.currentTime
|
||||
const newTime = getTargetTime(player, e)
|
||||
if (
|
||||
useSettings().preventSkippingVideos.data &&
|
||||
parseFloat(newTime) > current_time
|
||||
) {
|
||||
e.preventDefault()
|
||||
player.currentTime = current_time
|
||||
return false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
players.push(player)
|
||||
}
|
||||
|
||||
const getTargetTime = (plyr, input) => {
|
||||
if (
|
||||
typeof input === 'object' &&
|
||||
(input.type === 'input' || input.type === 'change')
|
||||
) {
|
||||
return (input.target.value / input.target.max) * plyr.duration
|
||||
} else {
|
||||
return Number(input)
|
||||
}
|
||||
}
|
||||
|
||||
const extractYouTubeId = (url) => {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
return (
|
||||
parsedUrl.searchParams.get('v') ||
|
||||
parsedUrl.pathname.split('/').pop()
|
||||
)
|
||||
} catch {
|
||||
return url.split('/').pop()
|
||||
}
|
||||
}
|
||||
|
||||
export const openSettings = (category, close = null) => {
|
||||
const settingsStore = useSettings()
|
||||
close()
|
||||
if (close) {
|
||||
close()
|
||||
}
|
||||
settingsStore.activeTab = category
|
||||
settingsStore.isSettingsOpen = true
|
||||
}
|
||||
|
||||
export const cleanError = (message) => {
|
||||
// Remove HTML tags but keep the text within the tags
|
||||
|
||||
const cleanMessage = message.replace(/<[^>]+>/g, (match) => {
|
||||
return match.replace(/<\/?[^>]+(>|$)/g, '')
|
||||
})
|
||||
@@ -618,7 +689,102 @@ export const updateMetaInfo = (type, route, meta) => {
|
||||
|
||||
export const formatTimestamp = (seconds) => {
|
||||
const date = new Date(seconds * 1000)
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0')
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
|
||||
const secs = String(date.getUTCSeconds()).padStart(2, '0')
|
||||
return `${minutes}:${secs}`
|
||||
return `${hours}:${minutes}:${secs}`
|
||||
}
|
||||
|
||||
const getRootNode = (selector = '#editor') => {
|
||||
const root = document.querySelector(selector)
|
||||
if (!root) {
|
||||
console.warn(`Root node not found for selector: ${selector}`)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const createTextWalker = (root, phrase) => {
|
||||
return document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
return node.nodeValue.toLowerCase().includes(phrase.toLowerCase())
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_SKIP
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const findMatchingTextNode = (walker, phrase) => {
|
||||
const node = walker.nextNode()
|
||||
if (!node) return null
|
||||
|
||||
const startIndex = node.nodeValue
|
||||
.toLowerCase()
|
||||
.indexOf(phrase.toLowerCase())
|
||||
const endIndex = startIndex + phrase.length
|
||||
|
||||
return { node, startIndex, endIndex }
|
||||
}
|
||||
|
||||
const createHighlightSpan = (color, name) => {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'highlighted-text'
|
||||
span.style.backgroundColor = theme.backgroundColor[color][200]
|
||||
span.dataset.name = name
|
||||
return span
|
||||
}
|
||||
|
||||
const wrapRangeInHighlight = ({ node, startIndex, endIndex }, color, name) => {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, startIndex)
|
||||
range.setEnd(node, endIndex)
|
||||
|
||||
const span = createHighlightSpan(color, name)
|
||||
range.surroundContents(span)
|
||||
}
|
||||
|
||||
export const highlightText = (note, scrollIntoView = false) => {
|
||||
if (!note?.highlighted_text) return
|
||||
|
||||
const root = getRootNode()
|
||||
if (!root) return
|
||||
|
||||
const phrase = note.highlighted_text
|
||||
const color = note.color.toLowerCase()
|
||||
|
||||
const walker = createTextWalker(root, phrase)
|
||||
const match = findMatchingTextNode(walker, phrase)
|
||||
if (!match) return
|
||||
|
||||
wrapRangeInHighlight(match, color, note.name)
|
||||
|
||||
if (scrollIntoView) {
|
||||
match.node.parentElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
setTimeout(() => {
|
||||
const highlightedElements =
|
||||
document.querySelectorAll('.highlighted-text')
|
||||
highlightedElements.forEach((el) => {
|
||||
if (el.dataset.name === note.name) {
|
||||
el.style.backgroundColor = 'transparent'
|
||||
}
|
||||
})
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
export const scrollToReference = (text) => {
|
||||
highlightText({ highlighted_text: text, color: 'yellow', name: '' }, true)
|
||||
}
|
||||
|
||||
export const blockQuotesClick = () => {
|
||||
document.querySelectorAll('blockquote').forEach((el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const text = e.target.textContent || ''
|
||||
if (text) {
|
||||
scrollToReference(text)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export class Markdown {
|
||||
this.config = config || {}
|
||||
this.text = data.text || ''
|
||||
this.readOnly = readOnly
|
||||
this.placeholder = __("Type '/' for commands or select text to format")
|
||||
}
|
||||
|
||||
static get isReadOnlySupported() {
|
||||
@@ -64,7 +65,15 @@ export class Markdown {
|
||||
this.wrapper.contentEditable = true
|
||||
this.wrapper.innerHTML = this.text
|
||||
|
||||
this.wrapper.addEventListener('focus', () =>
|
||||
this._togglePlaceholder()
|
||||
)
|
||||
this.wrapper.addEventListener('blur', () =>
|
||||
this._togglePlaceholder()
|
||||
)
|
||||
|
||||
this.wrapper.addEventListener('input', (event) => {
|
||||
this._togglePlaceholder()
|
||||
let value = event.target.textContent
|
||||
if (event.keyCode === 32 && value.startsWith('#')) {
|
||||
this.convertToHeader(event, value)
|
||||
@@ -85,6 +94,22 @@ export class Markdown {
|
||||
return this.wrapper
|
||||
}
|
||||
|
||||
_togglePlaceholder() {
|
||||
const blocks = document.querySelectorAll(
|
||||
'.cdx-block.ce-paragraph[data-placeholder]'
|
||||
)
|
||||
blocks.forEach((block) => {
|
||||
if (block !== this.wrapper) {
|
||||
delete block.dataset.placeholder
|
||||
}
|
||||
})
|
||||
if (this.wrapper.innerHTML.trim() === '') {
|
||||
this.wrapper.dataset.placeholder = this.placeholder
|
||||
} else {
|
||||
delete this.wrapper.dataset.placeholder
|
||||
}
|
||||
}
|
||||
|
||||
convertToHeader(event, value) {
|
||||
event.preventDefault()
|
||||
if (['#', '##', '###', '####', '#####', '######'].includes(value)) {
|
||||
|
||||
101
frontend/src/utils/program.ts
Normal file
101
frontend/src/utils/program.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createApp, h } from 'vue'
|
||||
import { Code } from 'lucide-vue-next'
|
||||
import translationPlugin from '@/translation'
|
||||
import ProgrammingExerciseModal from '@/pages/ProgrammingExercises/ProgrammingExerciseModal.vue';
|
||||
import { call } from 'frappe-ui';
|
||||
import { usersStore } from '@/stores/user'
|
||||
|
||||
|
||||
export class Program {
|
||||
|
||||
data: any;
|
||||
api: any;
|
||||
readOnly: boolean;
|
||||
wrapper: HTMLDivElement;
|
||||
|
||||
constructor({ data, api, readOnly }: { data: any; api: any; readOnly: boolean }) {
|
||||
this.data = data;
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
const app = createApp({
|
||||
render: () => h(Code, { size: 5, strokeWidth: 1.5 }),
|
||||
})
|
||||
|
||||
const div = document.createElement('div')
|
||||
app.mount(div)
|
||||
|
||||
return {
|
||||
title: __('Programming Exercise'),
|
||||
icon: div.innerHTML,
|
||||
}
|
||||
}
|
||||
|
||||
static get isReadOnlySupported() {
|
||||
return true
|
||||
}
|
||||
|
||||
render() {
|
||||
this.wrapper = document.createElement('div')
|
||||
if (Object.keys(this.data).length) {
|
||||
this.renderExercise(this.data.exercise)
|
||||
} else {
|
||||
this.renderModal()
|
||||
}
|
||||
return this.wrapper
|
||||
}
|
||||
|
||||
renderModal() {
|
||||
if (this.readOnly) {
|
||||
return
|
||||
}
|
||||
const app = createApp(ProgrammingExerciseModal, {
|
||||
onSave: (exercise: string) => {
|
||||
this.data.exercise = exercise
|
||||
this.renderExercise(exercise)
|
||||
},
|
||||
})
|
||||
app.use(translationPlugin)
|
||||
app.mount(this.wrapper)
|
||||
}
|
||||
|
||||
renderExercise(exercise: string) {
|
||||
if (this.readOnly) {
|
||||
const { userResource } = usersStore()
|
||||
call('frappe.client.get_value', {
|
||||
doctype: 'LMS Programming Exercise Submission',
|
||||
filters: {
|
||||
exercise: exercise,
|
||||
member: userResource.data?.name,
|
||||
},
|
||||
fieldname: ['name'],
|
||||
}).then((data: { name: string }) => {
|
||||
let submission = data.name || 'new'
|
||||
this.wrapper.innerHTML = `<iframe src="/lms/exercises/${exercise}/submission/${submission}?fromLesson=1" class="w-full h-[900px] border rounded-md"></iframe>`
|
||||
})
|
||||
return
|
||||
}
|
||||
call("frappe.client.get_value", {
|
||||
doctype: 'LMS Programming Exercise',
|
||||
name: exercise,
|
||||
fieldname: "title"
|
||||
}).then((data: { title: string }) => {
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
|
||||
<span class="font-medium">
|
||||
Programming Exercise: ${data.title}
|
||||
</span>
|
||||
</div>`
|
||||
return
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
save() {
|
||||
if (!this.data.exercise) return {}
|
||||
return {
|
||||
exercise: this.data.exercise,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,7 @@ export class Quiz {
|
||||
|
||||
static get toolbox() {
|
||||
const app = createApp({
|
||||
render: () =>
|
||||
h(CircleHelp, { size: 18, strokeWidth: 1.5, color: 'black' }),
|
||||
render: () => h(CircleHelp, { size: 5, strokeWidth: 1.5 }),
|
||||
})
|
||||
|
||||
const div = document.createElement('div')
|
||||
@@ -46,7 +45,7 @@ export class Quiz {
|
||||
this.wrapper.innerHTML = `<iframe src="/lms/quiz/${quiz}?fromLesson=1" class="w-full h-[500px]"></iframe>`
|
||||
return
|
||||
}
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-surface-menu-bar mb-2'>
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
|
||||
<span class="font-medium">
|
||||
Quiz: ${quiz}
|
||||
</span>
|
||||
@@ -69,7 +68,8 @@ export class Quiz {
|
||||
app.mount(this.wrapper)
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
save() {
|
||||
if (Object.keys(this.data).length === 0) return {}
|
||||
return {
|
||||
quiz: this.data.quiz,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import VideoBlock from '@/components/VideoBlock.vue'
|
||||
import UploadPlugin from '@/components/UploadPlugin.vue'
|
||||
import { h, createApp } from 'vue'
|
||||
import { Upload as UploadIcon } from 'lucide-vue-next'
|
||||
import { createDialog } from '@/utils/dialogs'
|
||||
import translationPlugin from '../translation'
|
||||
|
||||
export class Upload {
|
||||
@@ -54,6 +55,7 @@ export class Upload {
|
||||
},
|
||||
})
|
||||
app.use(translationPlugin)
|
||||
app.config.globalProperties.$dialog = createDialog
|
||||
app.mount(this.wrapper)
|
||||
return
|
||||
} else if (this.isAudio(file.file_type)) {
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"types": ["./globals"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/*.d.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
allowedHosts: ['fs', 'per2'],
|
||||
host: '0.0.0.0', // Accept connections from any network interface
|
||||
allowedHosts: ['ps', 'fs'], // Explicitly allow this host
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -39,6 +40,7 @@ export default defineConfig({
|
||||
'showdown',
|
||||
'engine.io-client',
|
||||
'tailwind.config.js',
|
||||
'interactjs',
|
||||
'highlight.js',
|
||||
'plyr',
|
||||
],
|
||||
|
||||
3068
frontend/yarn.lock
Normal file
3068
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
__version__ = "2.30.0"
|
||||
__version__ = "2.33.0"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user