Compare commits

..

1 Commits

Author SHA1 Message Date
Hussain Nagaria
d9bf4e2c58 fix: title and tab copy 2024-04-16 14:42:17 +05:30
167 changed files with 3977 additions and 23635 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

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

1
frappe-ui Submodule

Submodule frappe-ui added at c5faaae38e

View File

@@ -10,28 +10,24 @@
},
"dependencies": {
"@editorjs/checklist": "^1.6.0",
"@editorjs/code": "^2.9.0",
"@editorjs/editorjs": "^2.29.0",
"@editorjs/embed": "^2.7.0",
"@editorjs/header": "^2.8.1",
"@editorjs/inline-code": "^1.5.0",
"@editorjs/image": "^2.9.0",
"@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0",
"chart.js": "^4.4.1",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.56",
"lucide-vue-next": "^0.383.0",
"frappe-ui": "^0.1.50",
"lucide-vue-next": "^0.309.0",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",
"socket.io-client": "^4.7.2",
"tailwindcss": "^3.3.3",
"vue": "^3.4.23",
"vue": "^3.2.25",
"vue-chartjs": "^5.3.0",
"vue-draggable-next": "^2.2.1",
"vue-router": "^4.0.12",
"vuedraggable": "4.1.0"
"vue-router": "^4.0.12"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -19,14 +19,8 @@
<ListView
:columns="getCoursesColumns()"
:rows="courses.data"
row-key="batch_course"
:options="{
showTooltip: false,
getRowRoute: (row) => ({
name: 'CourseDetail',
params: { courseName: row.name },
}),
}"
row-key="name"
:options="{ showTooltip: false }"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
@@ -55,10 +49,7 @@
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeCourses(selections, unselectAll)"
>
<Button variant="ghost" @click="removeCourses(selections)">
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
@@ -117,16 +108,13 @@ const getCoursesColumns = () => {
{
label: 'Title',
key: 'title',
width: 2,
},
{
label: 'Lessons',
key: 'lesson_count',
align: 'right',
},
{
label: 'Enrollments',
align: 'right',
key: 'enrollment_count',
},
]
@@ -142,13 +130,12 @@ const removeCourse = createResource({
},
})
const removeCourses = (selections, unselectAll) => {
const removeCourses = (selections) => {
console.log(selections)
selections.forEach(async (course) => {
removeCourse.submit({ course })
await setTimeout(1000)
})
setTimeout(() => {
courses.reload()
unselectAll()
}, 1000)
courses.reload()
}
</script>

View File

@@ -1,12 +1,11 @@
<template>
<div v-if="batch.data" class="shadow rounded-md p-5 lg:w-72">
<div v-if="batch.data" class="shadow rounded-md p-5" style="width: 300px">
<Badge
v-if="batch.data.seat_count && seats_left > 0"
theme="green"
class="self-start mb-2 float-right"
>
{{ seats_left }} <span v-if="seats_left > 1">{{ __('Seats Left') }}</span
><span v-else-if="seats_left == 1">{{ __('Seat Left') }}</span>
{{ seats_left }} {{ __('Seat Left') }}
</Badge>
<Badge
v-else-if="batch.data.seat_count && seats_left <= 0"
@@ -22,26 +21,22 @@
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-3">
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
</span>
</div>
<div class="flex items-center">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div>
<div v-if="batch.data.timezone" class="flex items-center">
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ batch.data.timezone }}
</span>
</div>
<router-link
v-if="isModerator || isStudent"
v-if="user?.data?.is_moderator"
:to="{
name: 'Batch',
params: {
@@ -51,7 +46,7 @@
>
<Button variant="solid" class="w-full mt-4">
<span>
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
{{ __('Manage Batch') }}
</span>
</Button>
</router-link>
@@ -63,9 +58,9 @@
name: batch.data.name,
},
}"
v-else-if="batch.data.paid_batch && batch.data.seats_left"
v-else-if="batch.data.paid_batch"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<Button class="w-full mt-4" variant="solid">
<span>
{{ __('Register Now') }}
</span>
@@ -74,12 +69,12 @@
<Button
variant="solid"
class="w-full mt-2"
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
v-else-if="batch.data.allow_self_enrollment"
>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="isModerator"
v-if="user?.data?.is_moderator"
:to="{
name: 'BatchCreation',
params: {
@@ -96,12 +91,12 @@
</div>
</template>
<script setup>
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
import { inject, computed } from 'vue'
import { Badge, Button } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
const dayjs = inject('$dayjs')
const user = inject('$user')
const props = defineProps({
@@ -117,12 +112,4 @@ const seats_left = computed(() => {
}
return null
})
const isStudent = computed(() => {
return props.batch.data?.students?.includes(user.data?.name)
})
const isModerator = computed(() => {
return user.data?.is_moderator
})
</script>

View File

@@ -52,10 +52,7 @@
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Button variant="ghost" @click="removeStudents(selections)">
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
@@ -112,7 +109,6 @@ const getStudentColumns = () => {
{
label: 'Full Name',
key: 'full_name',
width: 2,
},
{
label: 'Courses Done',
@@ -145,13 +141,11 @@ const removeStudent = createResource({
},
})
const removeStudents = (selections, unselectAll) => {
const removeStudents = (selections) => {
selections.forEach(async (student) => {
removeStudent.submit({ student })
await setTimeout(1000)
})
setTimeout(() => {
students.reload()
unselectAll()
}, 500)
students.reload()
}
</script>

View File

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

View File

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

View File

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

View File

@@ -2,25 +2,15 @@
<div
v-if="course.title"
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
style="min-height: 350px"
style="min-height: 320px"
>
<div
class="course-image"
:class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
:style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"
>
<div
class="flex items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
>
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
{{ __('Featured') }}
</Badge>
<Badge
variant="outline"
theme="gray"
size="md"
v-for="tag in course.tags"
>
<div class="flex relative top-4 left-4 w-fit flex-wrap">
<Badge theme="gray" size="md" class="mr-2" v-for="tag in course.tags">
{{ tag }}
</Badge>
</div>
@@ -75,12 +65,15 @@
<div class="short-introduction">
{{ course.short_introduction }}
</div>
<ProgressBar
<div
v-if="user && course.membership"
:progress="course.membership.progress"
/>
class="w-full bg-gray-200 rounded-full h-1 mb-2"
>
<div
class="bg-gray-900 h-1 rounded-full"
:style="{ width: Math.ceil(course.membership.progress) + '%' }"
></div>
</div>
<div v-if="user && course.membership" class="text-sm mb-4">
{{ Math.ceil(course.membership.progress) }}% completed
</div>
@@ -88,7 +81,7 @@
<div class="flex items-center justify-between mt-auto">
<div class="flex avatar-group overlap">
<div
class="h-6 mr-1"
class="mr-1"
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
>
<UserAvatar
@@ -96,7 +89,17 @@
:user="instructor"
/>
</div>
<CourseInstructors :instructors="course.instructors" />
<span v-if="course.instructors.length == 1">
{{ course.instructors[0].full_name }}
</span>
<span v-if="course.instructors.length == 2">
{{ course.instructors[0].first_name }} and
{{ course.instructors[1].first_name }}
</span>
<span v-if="course.instructors.length > 2">
{{ course.instructors[0].first_name }} and
{{ course.instructors.length - 1 }} others
</span>
</div>
<div class="font-semibold">
@@ -111,8 +114,6 @@ import { BookOpen, Users, Star } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue'
import { sessionStore } from '@/stores/session'
import { Badge, Tooltip } from 'frappe-ui'
import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
const { user } = sessionStore()
@@ -149,8 +150,8 @@ const props = defineProps({
display: flex;
flex-direction: column;
align-items: center;
background-color: theme('colors.green.100');
color: theme('colors.green.600');
background-color: theme('colors.orange.100');
color: theme('colors.orange.600');
}
.avatar-group {

View File

@@ -16,10 +16,10 @@
params: {
courseName: course.name,
chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0]
? course.data.current_lesson.split('.')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
? course.data.current_lesson.split('.')[1]
: 1,
},
}"
@@ -46,12 +46,6 @@
</span>
</Button>
</router-link>
<div
v-else-if="course.data.disable_self_learning"
class="bg-blue-100 text-blue-900 text-sm rounded-md py-1 px-3"
>
{{ __('Contact the Administrator to enroll for this course.') }}
</div>
<Button
v-else
@click="enrollStudent()"
@@ -63,15 +57,6 @@
{{ __('Start Learning') }}
</span>
</Button>
<Button
v-if="canGetCertificate"
@click="fetchCertificate()"
variant="subtle"
class="w-full mt-2"
size="md"
>
{{ __('Get Certificate') }}
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
@@ -145,11 +130,12 @@ function enrollStudent() {
})
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 2000)
}, 3000)
} else {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
})
console.log(props.course)
enrollStudentResource
.submit({
course: props.course.data.name,
@@ -174,48 +160,5 @@ function enrollStudent() {
}
}
const is_instructor = () => {
let user_is_instructor = false
props.course.data.instructors.forEach((instructor) => {
if (!user_is_instructor && instructor.name == user.data?.name) {
user_is_instructor = true
}
})
return user_is_instructor
}
const canGetCertificate = computed(() => {
if (
props.course.data?.enable_certification &&
props.course.data?.membership?.progress == 100
) {
return true
}
return false
})
const certificate = createResource({
url: 'lms.lms.doctype.lms_certificate.lms_certificate.create_certificate',
makeParams(values) {
return {
course: values.course,
}
},
onSuccess(data) {
console.log(data)
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
data.name
}&format=${encodeURIComponent(data.template)}`,
'_blank'
)
},
})
const fetchCertificate = () => {
certificate.submit({
course: props.course.data?.name,
member: user.data?.name,
})
}
const is_instructor = () => {}
</script>

View File

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

View File

@@ -2,9 +2,9 @@
<div class="text-base">
<div
v-if="title && (outline.data?.length || allowEdit)"
class="grid grid-cols-[70%,30%] mb-4 px-2"
class="flex items-center justify-between mb-4"
>
<div class="font-semibold text-lg">
<div class="font-semibold" :class="allowEdit ? 'text-base' : 'text-lg'">
{{ __(title) }}
</div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
@@ -25,7 +25,7 @@
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
>
<DisclosureButton ref="" class="flex w-full p-2">
<DisclosureButton ref="" class="flex w-full px-2 py-3">
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
@@ -38,55 +38,42 @@
{{ chapter.title }}
</div>
</DisclosureButton>
<DisclosurePanel>
<Draggable
:list="chapter.lessons"
item-key="name"
group="items"
@end="updateOutline"
:data-chapter="chapter.name"
>
<template #item="{ element: lesson }">
<div class="outline-lesson pl-8 py-2 pr-4">
<router-link
:to="{
name: allowEdit ? 'CreateLesson' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
lessonNumber: lesson.number.split('.')[1],
},
}"
>
<div class="flex items-center text-sm leading-5 group">
<MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
{{ lesson.title }}
<Trash2
v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)"
class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-700 ml-2"
/>
</div>
</router-link>
</div>
</template>
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<DisclosurePanel class="pb-2">
<div v-for="lesson in chapter.lessons" :key="lesson.name">
<div class="outline-lesson pl-8 py-2">
<router-link
:to="{
name: allowEdit ? 'CreateLesson' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
lessonNumber: lesson.number.split('.')[1],
},
}"
>
<div class="flex items-center text-sm leading-5">
<MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
{{ lesson.title }}
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
/>
</div>
</router-link>
</div>
</div>
<div v-if="allowEdit" class="flex mt-2 pl-8">
<router-link
:to="{
name: 'CreateLesson',
@@ -119,7 +106,6 @@
<script setup>
import { Button, createResource } from 'frappe-ui'
import { ref } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
ChevronRight,
@@ -127,11 +113,9 @@ import {
HelpCircle,
FileText,
Check,
Trash2,
} from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils'
const route = useRoute()
const expandAll = ref(true)
@@ -155,10 +139,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
getProgress: {
type: Boolean,
default: false,
},
})
const outline = createResource({
@@ -166,47 +146,10 @@ const outline = createResource({
cache: ['course_outline', props.courseName],
params: {
course: props.courseName,
progress: props.getProgress,
},
auto: true,
})
const deleteLesson = createResource({
url: 'lms.lms.api.delete_lesson',
makeParams(values) {
return {
lesson: values.lesson,
chapter: values.chapter,
}
},
onSuccess() {
outline.reload()
showToast('Success', 'Lesson deleted successfully', 'check')
},
})
const updateLessonIndex = createResource({
url: 'lms.lms.api.update_lesson_index',
makeParams(values) {
return {
lesson: values.lesson,
sourceChapter: values.sourceChapter,
targetChapter: values.targetChapter,
idx: values.idx,
}
},
onSuccess() {
showToast('Success', 'Lesson moved successfully', 'check')
},
})
const trashLesson = (lessonName, chapterName) => {
deleteLesson.submit({
lesson: lessonName,
chapter: chapterName,
})
}
const openChapterDetail = (index) => {
return index == route.params.chapterNumber || index == 1
}
@@ -219,15 +162,6 @@ const openChapterModal = (chapter = null) => {
const getCurrentChapter = () => {
return currentChapter.value
}
const updateOutline = (e) => {
updateLessonIndex.submit({
lesson: e.item.__draggable_context.element.name,
sourceChapter: e.from.dataset.chapter,
targetChapter: e.to.dataset.chapter,
idx: e.newIndex,
})
}
</script>
<style>
.outline-lesson:has(.router-link-active) {

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="reviews.data?.length || membership" class="mt-20 mb-10">
<div v-if="reviews.data" class="mt-20 mb-10">
<Button
v-if="membership && !hasReviewed.data"
@click="openReviewModal()"
@@ -8,30 +8,18 @@
{{ __('Write a Review') }}
</Button>
<div class="flex items-center font-semibold text-2xl">
{{ __('Student Reviews') }}
<Star class="h-6 w-6 stroke-1 text-gray-50 fill-orange-500 mr-1" />
{{ avg_rating }} {{ __('ratings and ') }} {{ reviews.data.length }}
{{ __('reviews') }}
</div>
<div class="grid gap-8 mt-10">
<div v-for="(review, index) in reviews.data">
<div class="flex items-center">
<router-link
:to="{
name: 'Profile',
params: { username: review.owner_details.username },
}"
>
<UserAvatar :user="review.owner_details" :size="'2xl'" />
</router-link>
<UserAvatar :user="review.owner_details" :size="'2xl'" />
<div class="mx-4">
<router-link
:to="{
name: 'Profile',
params: { username: review.owner_details.username },
}"
>
<span class="text-lg font-medium mr-4">
{{ review.owner_details.full_name }}
</span>
</router-link>
<span class="text-lg font-medium mr-4">
{{ review.owner_details.full_name }}
</span>
<span>
{{ review.creation }}
</span>

View File

@@ -5,6 +5,7 @@
<div
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
>
<slot name="sidebar" />
<AppSidebar />
</div>
<div class="w-full overflow-auto" id="scrollContainer">

View File

@@ -69,11 +69,9 @@
/>
</div>
</div>
<TextEditor
class="mt-5"
:content="newReply"
:mentions="mentionUsers"
@change="(val) => (newReply = val)"
placeholder="Type your reply here..."
:fixedMenu="true"
@@ -94,14 +92,13 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted, computed } from 'vue'
import { ref, inject, onMounted } from 'vue'
import { createToast } from '../utils'
const showTopics = defineModel('showTopics')
const newReply = ref('')
const socket = inject('$socket')
const user = inject('$user')
const allUsers = inject('$allUsers')
const props = defineProps({
topic: {
@@ -150,16 +147,6 @@ const newReplyResource = createResource({
},
})
const mentionUsers = computed(() => {
let users = Object.values(allUsers.data).map((user) => {
return {
value: user.name,
label: user.full_name,
}
})
return users
})
const postReply = () => {
newReplyResource.submit(
{},

View File

@@ -40,16 +40,13 @@
<div v-else-if="singleThread && topics.data">
<DiscussionReplies :topic="topics.data" :singleThread="singleThread" />
</div>
<div
v-else
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
>
<MessageSquareText class="w-7 h-7 text-gray-500 stroke-1.5 mr-2" />
<div class="">
<div v-if="emptyStateTitle" class="font-medium mb-2">
<div v-else class="flex justify-center border mt-5 p-5 rounded-md">
<MessageSquareIcon class="w-10 h-10 stroke-1.5 text-gray-800 mr-2" />
<div>
<div class="text-xl font-semibold mb-2">
{{ __(emptyStateTitle) }}
</div>
<div class="text-gray-600">
<div>
{{ __(emptyStateText) }}
</div>
</div>
@@ -63,14 +60,13 @@
/>
</template>
<script setup>
import { createResource, Button } from 'frappe-ui'
import { createResource, Button, TextEditor } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue'
import { timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
import { MessageSquareText } from 'lucide-vue-next'
import { getScrollContainer } from '@/utils/scrollContainer'
import { MessageSquareIcon } from 'lucide-vue-next'
const showTopics = ref(true)
const currentTopic = ref(null)
@@ -93,20 +89,16 @@ const props = defineProps({
},
emptyStateTitle: {
type: String,
default: '',
default: 'No topics yet',
},
emptyStateText: {
type: String,
default: 'Start a discussion',
default: 'Be the first to start a discussion',
},
singleThread: {
type: Boolean,
default: false,
},
scrollToBottom: {
type: Boolean,
default: false,
},
})
onMounted(() => {
@@ -115,19 +107,8 @@ onMounted(() => {
socket.on('new_discussion_topic', (data) => {
topics.refresh()
})
if (props.scrollToBottom) {
setTimeout(() => {
scrollToEnd()
}, 100)
}
})
const scrollToEnd = () => {
let scrollContainer = getScrollContainer()
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
const topics = createResource({
url: 'lms.lms.utils.get_discussion_topics',
cache: ['topics', props.doctype, props.docname],

View File

@@ -1,30 +1,34 @@
<template>
<div class="flex rounded p-1 lg:px-2 lg:py-2.5 hover:bg-gray-100">
<div class="flex w-3/5 md:w-2/5">
<img
:src="job.company_logo"
class="w-12 h-12 rounded-lg object-contain mr-4"
:alt="job.company_name"
/>
<div>
<div class="font-medium mb-1">
{{ job.job_title }}
</div>
<div class="text-gray-700">
{{ job.company_name }}
</div>
<div class="flex shadow rounded-md p-4 h-full">
<img
:src="job.company_logo"
class="w-12 h-12 rounded-lg object-contain mr-4"
:alt="job.company_name"
/>
<div>
<div class="text-xl font-semibold mb-2">
{{ job.job_title }}
</div>
<div>
{{ __('posted by') }}
<span class="font-medium">
{{ job.company_name }}
</span>
</div>
<div class="flex items-center my-4">
<Badge :label="job.type" theme="green" size="lg" class="mr-4" />
<Badge :label="job.location.split(' ')[0]" theme="gray" size="lg">
<template #prefix>
<MapPin class="h-4 w-4 stroke-1.5" />
</template>
</Badge>
</div>
<div>
{{ __('posted on') }}
<span class="font-medium">
{{ dayjs(job.creation).format('DD MMM YYYY') }}
</span>
</div>
</div>
<div class="flex justify-end w-1/5 text-gray-700">
{{ job.location.replace(',', '').split(' ')[0] }}
</div>
<div
class="flex justify-end w-1/5 text-gray-700 text-right hidden md:block"
>
{{ job.type }}
</div>
<div class="flex justify-end w-1/5 text-sm text-gray-700 text-right">
{{ dayjs(job.creation).format('DD MMM YYYY') }}
</div>
</div>
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">

View File

@@ -24,12 +24,7 @@
<Quiz :quiz="getId(block)" />
</div>
<div v-else-if="block.includes('{{ Video')">
<video
controls
width="100%"
controlsList="nodownload"
oncontextmenu="return false;"
>
<video controls width="100%" controlsList="nodownload">
<source :src="getId(block)" type="video/mp4" />
</video>
</div>

View File

@@ -48,7 +48,7 @@
{{
uploading
? __('Uploading {0}%').format(progress)
: __('Upload a File')
: __('Upload an File')
}}
</Button>
</div>
@@ -68,34 +68,13 @@
</div>
</div>
</div>
<div class="mt-4">
<div class="text-xs text-gray-600 mb-1">
{{
__(
'To add a YouTube video, paste the URL of the video in the editor.'
)
}}
</div>
<YouTubeExplanation>
<template v-slot="{ togglePopover }">
<div
@click="togglePopover()"
class="flex items-center text-sm underline cursor-pointer"
>
<Info class="w-3 h-3 stroke-1.5 text-gray-700 mr-1" />
{{ __('Learn More') }}
</div>
</template>
</YouTubeExplanation>
</div>
</div>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
import { Plus, FileText, Info } from 'lucide-vue-next'
import { Plus, FileText } from 'lucide-vue-next'
import { ref, watch } from 'vue'
import YouTubeExplanation from '@/components/Modals/YouTubeExplanation.vue'
const quiz = ref(null)
const file = ref(null)
@@ -129,7 +108,7 @@ const addFile = (data) => {
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3'].includes(extension)) {
return 'Only image and video files are allowed.'
}
}

View File

@@ -2,7 +2,7 @@
<Button
v-if="user.data.is_moderator"
variant="solid"
class="float-right mb-5"
class="float-right mb-3"
@click="openLiveClassModal"
>
<template #prefix>
@@ -12,49 +12,48 @@
{{ __('Add Live Class') }}
</span>
</Button>
<div class="text-lg font-semibold mb-5">
<div class="text-lg font-semibold mb-4">
{{ __('Live Class') }}
</div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full p-3"
>
<div class="font-semibold text-lg mb-4">
{{ cls.title }}
</div>
<div class="mb-4">
{{ cls.description }}
</div>
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-5">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(cls.time) }}
</span>
</div>
<div class="flex items-center space-x-2 mt-auto">
<a
:href="cls.start_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
<div v-for="cls in liveClasses.data">
<div class="border rounded-md p-3">
<div class="font-semibold text-lg mb-4">
{{ cls.title }}
</div>
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-5">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(cls.time) }}
</span>
</div>
<div class="mb-5">
{{ cls.description }}
</div>
<div class="flex items-center gap-2">
<a
:href="cls.start_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
</div>
</div>
</div>

View File

@@ -18,7 +18,7 @@
@click="handleClick(tab)"
>
<component
:is="icons[tab.icon]"
:is="tab.icon"
class="h-6 w-6 stroke-1.5"
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
/>
@@ -29,42 +29,15 @@
<script setup>
import { getSidebarLinks } from '../utils'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import { computed, inject } from 'vue'
import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user'
import * as icons from 'lucide-vue-next'
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const router = useRouter()
let { userResource } = usersStore()
const tabs = computed(() => {
let links = getSidebarLinks()
if (user) {
links.push({
label: 'Profile',
icon: 'UserRound',
activeFor: [
'Profile',
'ProfileAbout',
'ProfileCertification',
'ProfileEvaluator',
'ProfileRoles',
],
})
links.push({
label: 'Log out',
icon: 'LogOut',
})
} else {
links.push({
label: 'Log in',
icon: 'LogIn',
})
}
return links
return getSidebarLinks()
})
let isActive = (tab) => {
@@ -77,13 +50,6 @@ const handleClick = (tab) => {
logout.submit().then(() => {
isLoggedIn = false
})
else if (tab.label == 'Profile')
router.push({
name: 'Profile',
params: {
username: userResource.data?.username,
},
})
else router.push({ name: tab.to })
}

View File

@@ -14,13 +14,7 @@
}"
>
<template #body-content>
<Link doctype="LMS Course" v-model="course" :label="__('Course')" />
<Link
doctype="Course Evaluator"
v-model="evaluator"
:label="__('Evaluator')"
class="mt-4"
/>
<Link doctype="LMS Course" v-model="course" />
</template>
</Dialog>
</template>
@@ -32,7 +26,6 @@ import { showToast } from '@/utils'
const show = defineModel()
const course = ref(null)
const evaluator = ref(null)
const courses = defineModel('courses')
const props = defineProps({
@@ -52,7 +45,6 @@ const createBatchCourse = createResource({
parenttype: 'LMS Batch',
parentfield: 'courses',
course: course.value,
evaluator: evaluator.value,
},
}
},
@@ -66,7 +58,6 @@ const addCourse = (close) => {
courses.value.reload()
close()
course.value = null
evaluator.value = null
},
onError(err) {
showToast('Error', err.message[0] || err, 'x')

View File

@@ -5,7 +5,7 @@
size: '2xl',
actions: [
{
label: 'Post',
label: 'Submit',
variant: 'solid',
onClick: (close) => submitTopic(close),
},
@@ -15,7 +15,10 @@
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<FormControl v-model="topic.title" :label="__('Title')" type="text" />
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Title') }}
</div>
<Input type="text" v-model="topic.title" />
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">
@@ -34,9 +37,8 @@
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import { reactive, defineModel, computed } from 'vue'
import { showToast } from '@/utils'
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
import { reactive, defineModel } from 'vue'
const topics = defineModel('reloadTopics')
@@ -91,14 +93,6 @@ const submitTopic = (close) => {
topicResource.submit(
{},
{
validate() {
if (!topic.title) {
return 'Title cannot be empty.'
}
if (!topic.reply) {
return 'Reply cannot be empty.'
}
},
onSuccess(data) {
replyResource.submit(
{
@@ -114,9 +108,6 @@ const submitTopic = (close) => {
}
)
},
onError(err) {
showToast('Error', err.message, 'x')
},
}
)
}

View File

@@ -1,109 +0,0 @@
<template>
<Popover transition="default">
<template #target="{ isOpen, togglePopover }" class="flex w-full">
<slot v-bind="{ isOpen, togglePopover }"></slot>
</template>
<template #body>
<div
class="absolute left-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
>
<div
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
>
<div class="flex items-center justify-center space-x-2">
<TextInput
type="text"
placeholder="search by keyword"
v-model="search"
:debounce="300"
class="flex-1"
/>
<FileUploader
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
</Button>
</div>
</template>
</FileUploader>
</div>
<div
class="relative mt-2 grid w-[25.5rem] gap-2 bg-white lg:grid-cols-2"
>
<button
v-for="image in images.data"
:key="image.id"
class="h-[50px] w-[200px] overflow-hidden rounded hover:opacity-80"
@click="$emit('select', image.urls.raw)"
>
<img
:src="
image.urls.raw +
'&w=200&h=50&fit=crop&crop=entropy,faces,focalpoint'
"
/>
</button>
</div>
<div
v-if="images.data"
class="mt-2 text-center text-sm text-gray-500"
>
{{ __('Image search powered by') }}
<a class="underline" target="_blank" href="https://unsplash.com">
{{ __('Unsplash') }}
</a>
</div>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import {
Popover,
TextInput,
FileUploader,
Button,
createResource,
} from 'frappe-ui'
import { ref, watch } from 'vue'
const search = ref(null)
const emit = defineEmits(['select'])
const images = createResource({
url: 'lms.lms.api.get_unsplash_photos',
makeParams: () => {
return {
keyword: search.value,
}
},
auto: true,
debounce: 500,
})
watch(
() => search.value,
() => {
images.reload()
}
)
const saveImage = (file) => {
emit('select', file.file_url)
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
}
}
</script>

View File

@@ -1,178 +0,0 @@
<template>
<Dialog
:options="{
title: 'Edit your profile',
size: 'xl',
actions: [
{
label: 'Save',
variant: 'solid',
onClick: (close) => saveProfile(close),
},
],
}"
>
<template #body-content>
<div>
<FileUploader
v-if="!profile.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? `Uploading ${progress}%`
: 'Upload a profile image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Profile Image') }}
</div>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
</div>
<div class="text-base flex flex-col">
<span>
{{ profile.image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(profile.image.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
<FormControl
v-model="profile.first_name"
:label="__('First Name')"
class="mb-4"
/>
<FormControl
v-model="profile.last_name"
:label="__('Last Name')"
class="mb-4"
/>
<FormControl
v-model="profile.headline"
:label="__('Headline')"
class="mb-4"
/>
<FormControl type="textarea" v-model="profile.bio" :label="__('Bio')" />
</div>
</template>
</Dialog>
</template>
<script setup>
import {
Dialog,
FormControl,
FileUploader,
Button,
createResource,
} from 'frappe-ui'
import { reactive, watch, defineModel } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { getFileSize, showToast } from '@/utils'
const reloadProfile = defineModel('reloadProfile')
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
const profile = reactive({
first_name: '',
last_name: '',
headline: '',
bio: '',
image: '',
})
const imageResource = createResource({
url: 'lms.lms.api.get_file_info',
makeParams(values) {
return {
file_url: values.image,
}
},
auto: false,
onSuccess(data) {
profile.image = data
},
})
const updateProfile = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'User',
name: props.profile.data.name,
fieldname: {
user_image: profile.image.file_url,
...profile,
},
}
},
onSuccess(data) {
props.profile.data = data
},
})
const saveProfile = (close) => {
updateProfile.submit(
{},
{
onSuccess() {
close()
reloadProfile.value.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
}
}
const saveImage = (file) => {
profile.image = file
}
const removeImage = () => {
profile.image = null
}
watch(
() => props.profile.data,
(newVal) => {
if (newVal) {
profile.first_name = newVal.first_name
profile.last_name = newVal.last_name
profile.headline = newVal.headline
profile.bio = newVal.bio
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
}
}
)
</script>

View File

@@ -25,7 +25,7 @@
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Date') }}
</div>
<FormControl type="date" v-model="evaluation.date" />
<DatePicker v-model="evaluation.date" />
</div>
<div v-if="slots.data?.length">
<div class="mb-1.5 text-sm text-gray-600">
@@ -46,10 +46,7 @@
</div>
</div>
</div>
<div
v-else-if="evaluation.course && evaluation.date"
class="text-sm italic text-red-600"
>
<div v-else class="text-sm italic text-red-600">
{{ __('No slots available for this date.') }}
</div>
</div>
@@ -57,7 +54,7 @@
</Dialog>
</template>
<script setup>
import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
import { Dialog, createResource, Select, DatePicker } from 'frappe-ui'
import { defineModel, reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/'
@@ -116,7 +113,7 @@ function submitEvaluation(close) {
if (!evaluation.start_time) {
return 'Please select a slot.'
}
if (dayjs(evaluation.date).isBefore(dayjs(), 'day')) {
if (dayjs(evaluation.date).isSameOrBefore(dayjs(), 'day')) {
return 'Please select a future date.'
}
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
@@ -130,14 +127,11 @@ function submitEvaluation(close) {
close()
},
onError(err) {
let message = err.messages?.[0] || err
let unavailabilityMessage = message.includes('unavailable')
createToast({
title: unavailabilityMessage ? 'Evaluator is Unavailable' : 'Error',
text: message,
icon: unavailabilityMessage ? 'alert-circle' : 'x',
iconClasses: 'bg-yellow-600 text-white rounded-md p-px',
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-red-600 text-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
@@ -171,7 +165,7 @@ watch(
() => evaluation.date,
(date) => {
evaluation.start_time = ''
if (date && evaluation.course) {
if (date) {
slots.submit(evaluation)
}
}

View File

@@ -17,61 +17,78 @@
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div>
<FormControl
type="text"
v-model="liveClass.title"
:label="__('Title')"
class="mb-4"
/>
<Tooltip
:text="
__(
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
)
"
>
<FormControl
v-model="liveClass.time"
type="time"
:label="__('Time')"
class="mb-4"
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Title') }}
</div>
<Input type="text" v-model="liveClass.title" />
</div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
<Tooltip
class="flex items-center"
:text="
__(
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
)
"
>
<span>
{{ __('Time') }}
</span>
<Info class="stroke-2 w-3 h-3 ml-1" />
</Tooltip>
</div>
<Input v-model="liveClass.time" />
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Timezone') }}
</div>
<Select
v-model="liveClass.timezone"
:options="getTimezoneOptions()"
/>
</Tooltip>
<FormControl
v-model="liveClass.timezone"
type="select"
:options="getTimezoneOptions()"
:label="__('Timezone')"
/>
</div>
</div>
<div>
<FormControl
v-model="liveClass.date"
type="date"
class="mb-4"
:label="__('Date')"
/>
<Tooltip :text="__('Duration of the live class in minutes')">
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration')"
class="mb-4"
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Date') }}
</div>
<DatePicker v-model="liveClass.date" inputClass="w-full" />
</div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
<Tooltip
class="flex items-center"
:text="__('Duration of the live class in minutes')"
>
<span>
{{ __('Duration') }}
</span>
<Info class="stroke-2 w-3 h-3 ml-1" />
</Tooltip>
</div>
<Input type="number" v-model="liveClass.duration" />
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Auto Recording') }}
</div>
<Select
v-model="liveClass.auto_recording"
:options="getRecordingOptions()"
/>
</Tooltip>
<FormControl
v-model="liveClass.auto_recording"
type="select"
:options="getRecordingOptions()"
:label="__('Auto Recording')"
/>
</div>
</div>
</div>
<FormControl
v-model="liveClass.description"
type="textarea"
:label="__('Description')"
/>
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Description') }}
</div>
<Textarea v-model="liveClass.description" />
</div>
</div>
</template>
</Dialog>
@@ -85,7 +102,6 @@ import {
Dialog,
createResource,
Tooltip,
FormControl,
} from 'frappe-ui'
import { reactive, inject } from 'vue'
import { getTimezones, createToast } from '@/utils/'
@@ -153,7 +169,7 @@ const createLiveClass = createResource({
})
const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, {
createLiveClass.submit(liveClass, {
validate() {
if (!liveClass.title) {
return 'Please enter a title.'

View File

@@ -1,90 +0,0 @@
<template>
<Dialog
v-model="show"
class="text-base"
:options="{
title: __('Add web page to sidebar'),
size: 'lg',
actions: [
{
label: 'Add',
variant: 'solid',
onClick: (close) => {
addWebPage(close)
},
},
],
}"
>
<template #body-content>
<Link
v-model="page.webpage"
doctype="Web Page"
:label="__('Web Page')"
:filters="{
published: 1,
}"
/>
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { reactive, watch } from 'vue'
import IconPicker from '@/components/Controls/IconPicker.vue'
import { showToast } from '@/utils'
const sidebar = defineModel('reloadSidebar')
const show = defineModel()
const page = reactive({
icon: '',
webpage: '',
})
const props = defineProps({
page: {
type: Object,
default: null,
},
})
const webPage = createResource({
url: 'lms.lms.api.update_sidebar_item',
makeParams(values) {
return {
webpage: page.webpage,
icon: page.icon,
}
},
})
watch(
() => props.page,
(newPage) => {
if (newPage) {
page.icon = newPage.icon
page.webpage = newPage.web_page
}
},
{ immediate: true }
)
const addWebPage = (close) => {
webPage.submit(
{},
{
onSuccess() {
sidebar.value.reload()
close()
showToast('Success', 'Web page added to sidebar', 'check')
},
onError(err) {
showToast('Error', err.message[0] || err, 'x')
close()
},
}
)
}
</script>

View File

@@ -1,31 +0,0 @@
<template>
<Popover transition="default">
<template #target="{ isOpen, togglePopover }" class="flex w-full">
<slot v-bind="{ isOpen, togglePopover }"></slot>
</template>
<template #body>
<div
class="absolute left-0 mt-3 w-[35rem] max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
>
<div
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
>
<video
controls
autoplay
muted
width="100%"
controlsList="nodownload"
oncontextmenu="return false;"
class="rounded-sm"
>
<source src="/Youtube.mov" type="video/mp4" />
</video>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import { Popover } from 'frappe-ui'
</script>

View File

@@ -1,42 +0,0 @@
<template>
<div class="border rounded-md w-1/3 mx-auto my-32">
<div class="border-b px-5 py-3 font-medium">
<span
class="inline-flex items-center before:bg-red-600 before:w-2 before:h-2 before:rounded-md before:mr-2"
></span>
{{ __('Not Permitted') }}
</div>
<div v-if="user.data" class="px-5 py-3">
<div>
{{ __('You do not have permission to access this page.') }}
</div>
<router-link
:to="{
name: 'Courses',
}"
>
<Button variant="solid" class="mt-2">
{{ __('Checkout Courses') }}
</Button>
</router-link>
</div>
<div class="px-5 py-3">
<div>
{{ __('Please login to access this page.') }}
</div>
<Button variant="solid" @click="redirectToLogin()" class="mt-2">
{{ __('Login') }}
</Button>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue'
import { Button } from 'frappe-ui'
const user = inject('$user')
const redirectToLogin = () => {
window.location.href = '/login'
}
</script>

View File

@@ -1,24 +0,0 @@
<template>
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
<div
class="bg-gray-900 h-1 rounded-full"
:style="{ width: progressBarWidth }"
></div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
progress: {
type: Number,
default: 0,
},
})
const progressBarWidth = computed(() => {
const formattedPercentage = Math.min(Math.ceil(props.progress), 100)
return `${formattedPercentage}%`
})
</script>

View File

@@ -3,7 +3,9 @@
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
<div class="leading-relaxed">
{{
__('This quiz consists of {0} questions.').format(questions.length)
__('This quiz consists of {0} questions.').format(
quiz.data.questions.length
)
}}
</div>
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
@@ -57,7 +59,7 @@
</div>
</div>
<div v-else-if="!quizSubmission.data">
<div v-for="(question, qtidx) in questions">
<div v-for="(question, qtidx) in quiz.data.questions">
<div
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
class="border rounded-md p-5"
@@ -67,10 +69,7 @@
<span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}:
</span>
<span v-if="questionDetails.data.type == 'User Input'">
{{ __('Type your answer') }}
</span>
<span v-else>
<span>
{{
questionDetails.data.multiple
? __('Choose all answers that apply')
@@ -83,10 +82,9 @@
{{ question.marks == 1 ? __('Mark') : __('Marks') }}
</div>
</div>
<div
class="text-gray-900 font-semibold mt-2"
v-html="questionDetails.data.question"
></div>
<div class="text-gray-900 font-semibold mt-2">
{{ questionDetails.data.question }}
</div>
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
<label
v-if="questionDetails.data[`option_${index}`]"
@@ -125,46 +123,23 @@
<MinusCircle v-else class="w-4 h-4" />
</div>
</div>
<span
class="ml-2"
v-html="questionDetails.data[`option_${index}`]"
>
<span class="ml-2">
{{ questionDetails.data[`option_${index}`] }}
</span>
</label>
<div
v-if="questionDetails.data[`explanation_${index}`]"
class="mt-2 text-xs"
v-show="showAnswers.length"
class="mt-2 text-sm hidden"
>
{{ questionDetails.data[`explanation_${index}`] }}
</div>
</div>
<div v-else>
<FormControl
v-model="possibleAnswer"
type="textarea"
:disabled="showAnswers.length ? true : false"
class="my-2"
/>
<div v-if="showAnswers.length">
<Badge v-if="showAnswers[0]" :label="__('Correct')" theme="green">
<template #prefix>
<CheckCircle class="w-4 h-4 text-green-500 mr-1" />
</template>
</Badge>
<Badge v-else theme="red" :label="__('Incorrect')">
<template #prefix>
<XCircle class="w-4 h-4 text-red-500 mr-1" />
</template>
</Badge>
</div>
</div>
<div class="flex items-center justify-between mt-5">
<div class="flex items-center justify-between mt-8">
<div>
{{
__('Question {0} of {1}').format(
activeQuestion,
questions.length
quiz.data.questions.length
)
}}
</div>
@@ -177,7 +152,7 @@
</span>
</Button>
<Button
v-else-if="activeQuestion != questions.length"
v-else-if="activeQuestion != quiz.data.questions.length"
@click="nextQuetion()"
>
<span>
@@ -236,20 +211,22 @@
</div>
</template>
<script setup>
import { Badge, Button, createResource, ListView } from 'frappe-ui'
import {
createDocumentResource,
Button,
createResource,
ListView,
} from 'frappe-ui'
import { ref, watch, reactive, inject } from 'vue'
import { createToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils'
import FormControl from 'frappe-ui/src/components/FormControl.vue'
const user = inject('$user')
const activeQuestion = ref(0)
const currentQuestion = ref('')
const selectedOptions = reactive([0, 0, 0, 0])
const showAnswers = reactive([])
let questions = reactive([])
const possibleAnswer = ref(null)
const props = defineProps({
quizName: {
@@ -269,28 +246,11 @@ const quiz = createResource({
cache: ['quiz', props.quizName],
auto: true,
onSuccess(data) {
shuffleQuiz()
attempts.reload()
resetQuiz()
},
})
const shuffleQuiz = () => {
let data = quiz.data
if (data.shuffle_questions) {
questions = shuffleArray(data.questions)
}
if (data.limit_questions_to) {
questions = questions.slice(0, data.limit_questions_to)
}
}
const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[array[i], array[j]] = [array[j], array[i]]
}
return array
}
const attempts = createResource({
url: 'frappe.client.get_list',
makeParams(values) {
@@ -319,16 +279,6 @@ const attempts = createResource({
},
})
watch(
() => quiz.data,
() => {
if (quiz.data) {
attempts.reload()
resetQuiz()
}
}
)
const quizSubmission = createResource({
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
makeParams(values) {
@@ -358,6 +308,7 @@ watch(activeQuestion, (value) => {
watch(
() => props.quizName,
(newName) => {
console.log(newName)
if (newName) {
quiz.reload()
}
@@ -377,17 +328,10 @@ const markAnswer = (index) => {
const getAnswers = () => {
let answers = []
const type = questionDetails.data.type
if (type == 'Choices') {
selectedOptions.forEach((value, index) => {
if (selectedOptions[index])
answers.push(questionDetails.data[`option_${index + 1}`])
})
} else {
answers.push(possibleAnswer.value)
}
selectedOptions.forEach((value, index) => {
if (selectedOptions[index])
answers.push(questionDetails.data[`option_${index + 1}`])
})
return answers
}
@@ -397,8 +341,7 @@ const checkAnswer = () => {
createToast({
title: 'Please select an option',
icon: 'alert-circle',
iconClasses: 'text-yellow-600 bg-yellow-100 rounded-full',
position: 'top-center',
iconClasses: 'text-yellow-600 bg-yellow-100',
})
return
}
@@ -412,20 +355,15 @@ const checkAnswer = () => {
},
auto: true,
onSuccess(data) {
let type = questionDetails.data.type
if (type == 'Choices') {
selectedOptions.forEach((option, index) => {
if (option) {
showAnswers[index] = option && data[index]
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
showAnswers[index] = 0
} else {
showAnswers[index] = undefined
}
})
} else {
showAnswers.push(data)
}
selectedOptions.forEach((option, index) => {
if (option) {
showAnswers[index] = option && data[index]
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
showAnswers[index] = 0
} else {
showAnswers[index] = undefined
}
})
addToLocalStorage()
if (!quiz.data.show_answers) {
resetQuestion()
@@ -438,7 +376,7 @@ const addToLocalStorage = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
let questionData = {
question_index: activeQuestion.value,
answer: getAnswers().join(),
answers: getAnswers().join(),
is_correct: showAnswers.filter((answer) => {
return answer != undefined
}),
@@ -460,7 +398,6 @@ const resetQuestion = () => {
activeQuestion.value = activeQuestion.value + 1
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
showAnswers.length = 0
possibleAnswer.value = null
}
const submitQuiz = () => {
@@ -485,7 +422,6 @@ const resetQuiz = () => {
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
showAnswers.length = 0
quizSubmission.reset()
shuffleQuiz()
}
const getSubmissionColumns = () => {

View File

@@ -23,8 +23,4 @@ const props = defineProps({
required: true,
},
})
const redirectToLogin = () => {
window.location.href = `/login`
}
</script>

View File

@@ -6,21 +6,21 @@
@click="handleClick"
>
<div
class="flex items-center w-full duration-300 ease-in-out group"
class="flex items-center duration-300 ease-in-out"
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
>
<Tooltip :text="link.label" placement="right">
<slot name="icon">
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<component
:is="icons[link.icon]"
class="h-4 w-4 stroke-1.5 text-gray-800"
:is="link.icon"
class="h-5 w-5 stroke-1.5 text-gray-800"
/>
</span>
</slot>
</Tooltip>
<span
class="flex-shrink-0 text-sm duration-300 ease-in-out"
class="flex-shrink-0 text-base duration-300 ease-in-out"
:class="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'
@@ -29,35 +29,16 @@
>
{{ link.label }}
</span>
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
{{ link.count }}
</span>
<div
v-if="showControls && !isCollapsed"
class="flex items-center space-x-2 !ml-auto block text-xs text-gray-600 group-hover:visible invisible"
>
<component
:is="icons['Edit']"
class="h-3 w-3 stroke-1.5 text-gray-700"
@click.stop="openModal(link)"
/>
<component
:is="icons['X']"
class="h-3 w-3 stroke-1.5 text-gray-700"
@click.stop="deletePage(link)"
/>
</div>
</div>
</button>
</template>
<script setup>
import { Tooltip, Button } from 'frappe-ui'
import { Tooltip } from 'frappe-ui'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import * as icons from 'lucide-vue-next'
const router = useRouter()
const emit = defineEmits(['openModal', 'deletePage'])
const props = defineProps({
link: {
@@ -68,29 +49,13 @@ const props = defineProps({
type: Boolean,
default: false,
},
showControls: {
type: Boolean,
default: false,
},
})
function handleClick() {
if (router.hasRoute(props.link.to)) {
router.push({ name: props.link.to })
} else if (props.link.to) {
window.location.href = `/${props.link.to}`
}
router.push({ name: props.link.to })
}
const isActive = computed(() => {
let isActive = computed(() => {
return props.link?.activeFor?.includes(router.currentRoute.value.name)
})
const openModal = (link) => {
emit('openModal', link)
}
const deletePage = (link) => {
emit('deletePage', link)
}
</script>

View File

@@ -34,7 +34,9 @@ const props = defineProps({
default: 'Tags',
},
})
console.log(props.modelValue)
let tags = ref(props.modelValue)
console.log(tags.value)
const emit = defineEmits(['update:modelValue'])
let newTag = ref('')

View File

@@ -1,90 +0,0 @@
<template>
<Popover transition="default">
<template #target="{ isOpen, togglePopover }" class="flex w-full">
<slot v-bind="{ isOpen, togglePopover }"></slot>
</template>
<template #body>
<div
class="absolute left-1/2 mt-3 max-w-sm -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
>
<div
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
>
<div class="flex items-center space-x-2">
<div class="flex-1">
<TextInput
type="text"
placeholder="search by keyword"
v-model="search"
:debounce="300"
/>
</div>
<FileUploader @success="(file) => $emit('select', file.file_url)">
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="w-full text-center">
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
</Button>
</div>
</template>
</FileUploader>
</div>
<div
class="relative mt-2 grid w-[25.5rem] gap-2 bg-white lg:grid-cols-2"
>
<Button
v-for="image in $resources.images.data"
:key="image.id"
class="h-[50px] w-[200px] overflow-hidden rounded hover:opacity-80"
@click="$emit('select', image.urls.raw)"
>
<img
:src="
image.urls.raw +
'&w=200&h=50&fit=crop&crop=entropy,faces,focalpoint'
"
/>
</Button>
</div>
<div class="mt-2 text-center text-sm text-gray-500">
{{ __('Image search powered by') }}
<a class="underline" target="_blank" href="https://unsplash.com">
{{ __('Unsplash') }}
</a>
</div>
</div>
</div>
</template>
</Popover>
</template>
<script>
// import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
import { Popover, FileUploader, Button } from 'frappe-ui'
export default {
name: 'UnsplashImageBrowser',
components: {
Popover,
FileUploader,
},
emits: ['select'],
resources: {
images() {
return {
url: 'gameplan.api.get_unsplash_photos',
params: { keyword: this.search },
auto: true,
debounce: 500,
}
},
},
data() {
return {
search: '',
}
},
}
</script>

View File

@@ -1,17 +1,15 @@
<template>
<Tooltip :text="user.full_name">
<Avatar
class="avatar border border-gray-300 cursor-auto"
v-if="user"
:label="user.full_name"
:image="user.user_image"
:size="size"
v-bind="$attrs"
/>
</Tooltip>
<Avatar
class="avatar border border-gray-300"
v-if="user"
:label="user.full_name"
:image="user.user_image"
:size="size"
v-bind="$attrs"
/>
</template>
<script setup>
import { Avatar, Tooltip } from 'frappe-ui'
import { Avatar } from 'frappe-ui'
const props = defineProps({
user: {
type: Object,

View File

@@ -26,21 +26,13 @@
"
>
<div class="text-base font-medium text-gray-900 leading-none">
<span
v-if="
branding.data?.brand_name &&
branding.data?.brand_name != 'Frappe'
"
>
<span v-if="branding.data?.brand_name">
{{ branding.data?.brand_name }}
</span>
<span v-else> Learning </span>
</div>
<div
v-if="userResource"
class="mt-1 text-sm text-gray-700 leading-none"
>
{{ convertToTitleCase(userResource.data?.full_name) }}
<div v-if="user" class="mt-1 text-sm text-gray-700 leading-none">
{{ convertToTitleCase(user.split('@')[0]) }}
</div>
</div>
<div
@@ -61,23 +53,13 @@
<script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui'
import {
ChevronDown,
LogIn,
LogOut,
User,
ArrowRightLeft,
} from 'lucide-vue-next'
import { Dropdown, createResource } from 'frappe-ui'
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '../utils'
import { usersStore } from '@/stores/user'
import { onMounted } from 'vue'
const router = useRouter()
const { logout, branding } = sessionStore()
let { userResource } = usersStore()
let { isLoggedIn } = sessionStore()
const props = defineProps({
isCollapsed: {
type: Boolean,
@@ -85,30 +67,28 @@ const props = defineProps({
},
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
cache: true,
auto: true,
onSuccess(data) {
document.querySelector("link[rel='icon']").href = data.favicon
},
})
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const userDropdownOptions = [
{
/* {
icon: User,
label: 'My Profile',
onClick: () => {
router.push(`/user/${userResource.data?.username}`)
router.push(`/user/${user.data?.username}`)
},
condition: () => {
return isLoggedIn
},
},
{
icon: ArrowRightLeft,
label: 'Switch to Desk',
onClick: () => {
window.location.href = '/app'
},
condition: () => {
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
let system_user = cookies.get('system_user')
if (system_user === 'yes') return true
else return false
},
},
}, */
{
icon: LogOut,
label: 'Log out',

View File

@@ -1,182 +0,0 @@
<template>
<div ref="videoContainer" class="video-block group relative">
<video
@timeupdate="updateTime"
@ended="videoEnded"
class="rounded-lg border border-gray-100"
>
<source :src="fileURL" :type="type" />
</video>
<div
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
>
<Button variant="ghost">
<template #icon>
<Play
v-if="!playing"
@click="playVideo"
class="w-4 h-4 text-gray-900"
/>
<Pause v-else @click="pauseVideo" class="w-4 h-4 text-gray-900" />
</template>
</Button>
<Button variant="ghost" @click="toggleMute">
<template #icon>
<Volume2 v-if="!muted" class="w-4 h-4 text-gray-900" />
<VolumeX v-else class="w-4 h-4 text-gray-900" />
</template>
</Button>
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider w-full h-1"
/>
<span class="text-xs font-medium">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
<Button variant="ghost" @click="toggleFullscreen">
<template #icon>
<Maximize class="w-4 h-4 text-gray-900" />
</template>
</Button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { Play, Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui'
const videoRef = ref(null)
const videoContainer = ref(null)
let playing = ref(false)
let currentTime = ref(0)
let duration = ref(0)
let muted = ref(false)
const props = defineProps({
file: {
type: String,
required: true,
},
type: {
type: String,
default: 'video/mp4',
},
})
onMounted(() => {
setTimeout(() => {
videoRef.value = document.querySelector('video')
videoRef.value.onloadedmetadata = () => {
duration.value = videoRef.value.duration
}
videoRef.value.ontimeupdate = () => {
currentTime.value = videoRef.value.currentTime
}
}, 0)
})
const fileURL = computed(() => {
if (isYoutube) {
let url = props.file
if (url.includes('watch?v=')) {
url = url.replace('watch?v=', 'embed/')
}
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
}
return props.file
})
const isYoutube = computed(() => {
return props.type == 'video/youtube'
})
const playVideo = () => {
videoRef.value.play()
playing.value = true
}
const pauseVideo = () => {
videoRef.value.pause()
playing.value = false
}
const videoEnded = () => {
playing.value = false
}
const toggleMute = () => {
videoRef.value.muted = !videoRef.value.muted
muted.value = videoRef.value.muted
}
const changeCurrentTime = () => {
videoRef.value.currentTime = currentTime.value
}
const formatTime = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}
const toggleFullscreen = () => {
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
videoContainer.value.requestFullscreen()
}
}
</script>
<style scoped>
.video-block {
width: 100%;
max-width: 900px;
margin: 0 auto;
}
.video-block video {
width: 100%;
height: auto;
}
iframe {
width: 100%;
min-height: 500px;
}
.duration-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
background-color: theme('colors.gray.400');
cursor: pointer;
}
.duration-slider::-webkit-slider-thumb {
height: 10px;
width: 10px;
-webkit-appearance: none;
background-color: theme('colors.gray.900');
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
input[type='range'] {
overflow: hidden;
width: 150px;
-webkit-appearance: none;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
cursor: pointer;
box-shadow: -500px 0 0 500px theme('colors.gray.900');
}
}
</style>

View File

@@ -30,9 +30,8 @@ app.provide('$dayjs', dayjs)
app.provide('$socket', initSocket())
app.mount('#app')
const { userResource, allUsers } = usersStore()
const { userResource } = usersStore()
let { isLoggedIn } = sessionStore()
app.provide('$user', userResource)
app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource

View File

@@ -1,90 +0,0 @@
<template>
<div v-if="badge.doc">
<div class="p-5 flex flex-col items-center mt-40">
<div class="text-3xl font-semibold">
{{ badge.doc.title }}
</div>
<img :src="badge.doc.image" :alt="badge.doc.title" class="h-60 mt-2" />
<div class="text-lg">
{{
__('This badge has been awarded to {0} on {1}.').format(
userName,
dayjs(issuedOn.data?.issued_on).format('DD MMM YYYY')
)
}}
</div>
<div class="text-lg mt-2">
{{ badge.doc.description }}
</div>
</div>
</div>
</template>
<script setup>
import { createDocumentResource, createResource, Breadcrumbs } from 'frappe-ui'
import { computed, inject } from 'vue'
import { useRouter } from 'vue-router'
const allUsers = inject('$allUsers')
const dayjs = inject('$dayjs')
const router = useRouter()
const props = defineProps({
badgeName: {
type: String,
required: true,
},
email: {
type: String,
required: true,
},
})
const badge = createDocumentResource({
doctype: 'LMS Badge',
name: props.badgeName,
})
const userName = computed(() => {
const user = Object.values(allUsers.data).find(
(user) => user.name === props.email
)
return user ? user.full_name : props.email
})
const issuedOn = createResource({
url: 'frappe.client.get_value',
makeParams(values) {
return {
doctype: 'LMS Badge Assignment',
filters: {
member: props.email,
badge: props.badgeName,
},
fieldname: 'issued_on',
}
},
onSuccess(data) {
if (!data.issued_on) {
router.push({ name: 'Courses' })
}
},
auto: true,
})
const breadcrumbs = computed(() => {
return [
{
label: 'Badges',
},
{
label: badge.doc.title,
route: {
name: 'Badge',
params: {
badge: badge.doc.name,
},
},
},
]
})
</script>

View File

@@ -13,9 +13,9 @@
</template>
</Button>
</header>
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-full">
<div class="border-r-2">
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-y-hidden">
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
<template #tab="{ tab, selected }" class="overflow-x-hidden">
<div>
<button
@@ -43,7 +43,7 @@
</div>
</template>
<template #default="{ tab }">
<div class="pt-5 px-5 pb-10">
<div class="pt-5 px-10 pb-10">
<div v-if="tab.label == 'Courses'">
<BatchCourses :batch="batch.data.name" />
</div>
@@ -66,10 +66,9 @@
<Discussions
doctype="LMS Batch"
:docname="batch.data.name"
:title="__('Discussions')"
title="Discussions"
:key="batch.data.name"
:singleThread="true"
:scrollToBottom="true"
/>
</div>
</div>
@@ -80,40 +79,21 @@
<div class="text-2xl font-semibold mb-2">
{{ batch.data.title }}
</div>
<div v-html="batch.data.description" class="leading-5 mb-2"></div>
<div class="flex avatar-group overlap mb-5">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
<div class="flex items-center mb-3">
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ dayjs(batch.data.start_date).format('DD MMMM YYYY') }} -
{{ dayjs(batch.data.end_date).format('DD MMMM YYYY') }}
</span>
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-4">
<div class="flex items-center mb-6">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div>
<div v-if="batch.data.timezone" class="flex items-center mb-4">
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ batch.data.timezone }}
</span>
</div>
</div>
<AnnouncementModal
v-model="showAnnouncementModal"
@@ -169,9 +149,8 @@
<script setup>
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
import { computed, inject, ref } from 'vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import {
Calendar,
Clock,
LayoutDashboard,
BookOpen,
@@ -181,9 +160,8 @@ import {
Mail,
SendIcon,
MessageCircle,
Globe,
} from 'lucide-vue-next'
import { formatTime, updateDocumentTitle } from '@/utils'
import { formatTime } from '@/utils'
import BatchDashboard from '@/components/BatchDashboard.vue'
import BatchCourses from '@/components/BatchCourses.vue'
import LiveClass from '@/components/LiveClass.vue'
@@ -192,8 +170,8 @@ import Assessments from '@/components/Assessments.vue'
import Announcements from '@/components/Annoucements.vue'
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
import Discussions from '@/components/Discussions.vue'
import DateRange from '@/components/Common/DateRange.vue'
const dayjs = inject('$dayjs')
const user = inject('$user')
const showAnnouncementModal = ref(false)
@@ -286,13 +264,4 @@ const redirectToLogin = () => {
const openAnnouncementModal = () => {
showAnnouncementModal.value = true
}
const pageMeta = computed(() => {
return {
title: batch.data?.title,
description: batch.data?.description,
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -8,86 +8,76 @@
{{ __('Save') }}
</Button>
</header>
<div class="w-1/2 mx-auto py-5">
<div class="">
<div class="py-5">
<div class="container">
<div class="text-lg font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-2 gap-10 mb-4">
<div class="grid grid-cols-2 gap-10">
<div>
<FormControl
v-model="batch.title"
:label="__('Title')"
class="mb-4"
/>
<FormControl
v-model="batch.description"
:label="__('Description')"
type="textarea"
class="mb-4"
/>
</div>
<div class="flex flex-col space-y-2">
<div>
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
/>
<FormControl
v-model="batch.allow_self_enrollment"
type="checkbox"
:label="__('Allow self enrollment')"
/>
</div>
</div>
</div>
<div class="mb-4">
<div>
<FileUploader
v-if="!batch.image"
class="mt-4"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
</Button>
<FileUploader
v-if="!batch.image"
class="mt-4"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mt-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Meta Image') }}
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Meta Image') }}
</div>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span>
{{ batch.image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(batch.image.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
<div class="flex flex-col">
<span>
{{ batch.image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(batch.image.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
/>
</div>
<div class="mb-4">
<FormControl
v-model="batch.description"
:label="__('Description')"
type="textarea"
class="my-4"
/>
<div class="container border-b mb-5">
<div>
<label class="block text-sm text-gray-600 mb-1">
{{ __('Batch Details') }}
@@ -101,9 +91,9 @@
/>
</div>
</div>
<div class="mb-4">
<div class="container border-b mb-5">
<div class="text-lg font-semibold mb-4">
{{ __('Date and Time') }}
{{ __('Settings') }}
</div>
<div class="grid grid-cols-2 gap-10">
<div>
@@ -119,8 +109,6 @@
type="date"
class="mb-4"
/>
</div>
<div>
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
@@ -133,20 +121,7 @@
type="time"
class="mb-4"
/>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
class="mb-4"
/>
</div>
</div>
</div>
<div class="mb-4">
<div class="text-lg font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-2 gap-10">
<div>
<FormControl
v-model="batch.seat_count"
@@ -160,8 +135,6 @@
type="date"
class="mb-4"
/>
</div>
<div>
<FormControl
v-model="batch.medium"
type="select"
@@ -187,7 +160,7 @@
</div>
</div>
<div class="">
<div class="container">
<div class="text-lg font-semibold mb-4">
{{ __('Payment') }}
</div>
@@ -215,14 +188,7 @@
</div>
</template>
<script setup>
import {
computed,
onMounted,
inject,
reactive,
onBeforeUnmount,
ref,
} from 'vue'
import { computed, onMounted, inject, reactive } from 'vue'
import {
Breadcrumbs,
FormControl,
@@ -232,7 +198,6 @@ import {
createResource,
} from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import { useRouter } from 'vue-router'
import { getFileSize, showToast } from '../utils'
import { X, FileText } from 'lucide-vue-next'
@@ -256,41 +221,21 @@ const batch = reactive({
end_date: '',
start_time: '',
end_time: '',
timezone: '',
evaluation_end_date: '',
seat_count: '',
medium: '',
category: '',
allow_self_enrollment: false,
image: null,
paid_batch: false,
currency: '',
amount: 0,
})
const instructors = ref([])
onMounted(() => {
if (!user.data) window.location.href = '/login'
if (props.batchName != 'new') {
batchDetail.reload()
}
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
saveBatch()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const newBatch = createResource({
@@ -299,10 +244,7 @@ const newBatch = createResource({
return {
doc: {
doctype: 'LMS Batch',
meta_image: batch.image?.file_url,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
meta_image: batch.image.file_url,
...batch,
},
}
@@ -319,13 +261,9 @@ const batchDetail = createResource({
},
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (key == 'instructors') {
data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
if (Object.hasOwn(batch, key)) batch[key] = data[key]
})
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment']
let checkboxes = ['published', 'paid_batch']
for (let idx in checkboxes) {
let key = checkboxes[idx]
batch[key] = batch[key] ? true : false
@@ -341,10 +279,7 @@ const editBatch = createResource({
doctype: 'LMS Batch',
name: props.batchName,
fieldname: {
meta_image: batch.image?.file_url,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
meta_image: batch.image.file_url,
...batch,
},
}

View File

@@ -11,23 +11,20 @@
<div class="my-3">
{{ batch.data.description }}
</div>
<div
class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center justify-between lg:w-1/2"
>
<div class="flex items-center justify-between w-1/2">
<div class="flex items-center">
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
</div>
<span class="hidden lg:block" v-if="batch.data.courses"
>&middot;</span
>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
/>
<span class="hidden lg:block" v-if="batch.data.start_date"
>&middot;</span
>
<span v-if="batch.data.courses">&middot;</span>
<div class="flex items-center">
<Calendar class="h-4 w-4 text-gray-700 mr-2" />
<span>
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
</span>
</div>
<span v-if="batch.data.start_date">&middot;</span>
<div class="flex items-center">
<Clock class="h-4 w-4 text-gray-700 mr-2" />
<span>
@@ -36,29 +33,15 @@
</span>
</div>
</div>
<div class="flex avatar-group overlap mt-3">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
</div>
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
<div class="order-2 lg:order-none">
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
<div class="">
<div
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6"
v-html="batch.data.batch_details"
></div>
</div>
<div class="order-1 lg:order-none">
<div>
<BatchOverlay :batch="batch" />
</div>
</div>
@@ -68,7 +51,7 @@
{{ __('Courses') }}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-5">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
<div
v-if="batch.data.courses"
v-for="course in courses.data"
@@ -97,17 +80,15 @@
</div>
</template>
<script setup>
import { computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Clock } from 'lucide-vue-next'
import { formatTime, updateDocumentTitle } from '@/utils'
import { Breadcrumbs, createResource } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'
import { Breadcrumbs, createResource, Button } from 'frappe-ui'
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
import { formatTime } from '../utils'
import { computed, inject, ref } from 'vue'
import BatchOverlay from '@/components/BatchOverlay.vue'
import DateRange from '../components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import CourseCard from '@/components/CourseCard.vue'
import { useRouter } from 'vue-router'
const dayjs = inject('$dayjs')
const user = inject('$user')
const router = useRouter()
@@ -125,6 +106,16 @@ const batch = createResource({
batch: props.batchName,
},
auto: true,
onSuccess(data) {
if (data.students?.includes(user.data?.name)) {
router.push({
name: 'Batch',
params: {
batchName: props.batchName,
},
})
}
},
})
const courses = createResource({
@@ -144,15 +135,6 @@ const breadcrumbs = computed(() => {
})
return items
})
const pageMeta = computed(() => {
return {
title: batch.data?.title,
description: batch.data?.description,
}
})
updateDocumentTitle(pageMeta)
</script>
<style>
.batch-description p {

View File

@@ -7,17 +7,9 @@
class="h-7"
:items="[{ label: __('All Batches'), route: { name: 'Batches' } }]"
/>
<div class="flex space-x-2">
<div class="w-40">
<Select
v-if="categories.data?.length"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Filter')"
/>
</div>
<div class="flex">
<router-link
v-if="user.data?.is_moderator"
v-if="user.data"
:to="{
name: 'BatchCreation',
params: { batchName: 'new' },
@@ -41,7 +33,7 @@
</div>
<Tabs
v-model="tabIndex"
:tabs="makeTabs"
:tabs="tabs"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
>
<template #tab="{ tab, selected }">
@@ -70,7 +62,7 @@
<template #default="{ tab }">
<div
v-if="tab.batches && tab.batches.value.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 m-5"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mt-5 mx-5"
>
<router-link
v-for="batch in tab.batches.value"
@@ -95,29 +87,12 @@
</div>
</template>
<script setup>
import {
createListResource,
createResource,
Breadcrumbs,
Button,
Tabs,
Badge,
Select,
} from 'frappe-ui'
import { createListResource, Breadcrumbs, Button, Tabs, Badge } from 'frappe-ui'
import { Plus } from 'lucide-vue-next'
import BatchCard from '@/components/BatchCard.vue'
import { inject, ref, computed, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils'
import { inject, ref, computed } from 'vue'
const user = inject('$user')
const currentCategory = ref(null)
onMounted(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('category')) {
currentCategory.value = queries.get('category')
}
})
const batches = createListResource({
doctype: 'LMS Batch',
@@ -126,82 +101,32 @@ const batches = createListResource({
auto: true,
})
const categories = createResource({
url: 'lms.lms.api.get_categories',
makeParams() {
return {
doctype: 'LMS Batch',
filters: {
published: 1,
},
}
},
cache: ['batchCategories'],
auto: true,
transform(data) {
data.unshift({
label: '',
value: null,
})
},
})
const tabIndex = ref(0)
let tabs
const tabs = [
{
label: 'Upcoming',
batches: computed(() => batches.data?.upcoming || []),
count: computed(() => batches.data?.upcoming?.length),
},
]
const makeTabs = computed(() => {
tabs = []
addToTabs('Upcoming')
if (user.data?.is_moderator) {
addToTabs('Archived')
addToTabs('Private')
}
if (user.data) {
addToTabs('Enrolled')
}
return tabs
})
const getBatches = (type) => {
if (currentCategory.value && currentCategory.value != '') {
return batches.data[type].filter(
(batch) => batch.category == currentCategory.value
)
}
return batches.data[type]
}
const addToTabs = (label) => {
let batches = getBatches(label.toLowerCase().split(' ').join('_'))
if (user.data?.is_moderator) {
tabs.push({
label,
batches: computed(() => batches),
count: computed(() => batches.length),
label: 'Archived',
batches: computed(() => batches.data?.archived),
count: computed(() => batches.data?.archived?.length),
})
tabs.push({
label: 'Private',
batches: computed(() => batches.data?.private),
count: computed(() => batches.data?.private?.length),
})
}
if (user.data) {
tabs.push({
label: 'Enrolled',
batches: computed(() => batches.data?.enrolled),
count: computed(() => batches.data?.enrolled?.length),
})
}
watch(
() => currentCategory.value,
() => {
let queries = new URLSearchParams(location.search)
if (currentCategory.value) {
queries.set('category', currentCategory.value)
} else {
queries.delete('category')
}
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
}
)
const pageMeta = computed(() => {
return {
title: 'Batches',
description: 'All batches divided by categories',
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -161,7 +161,7 @@
<div v-else-if="!user.data?.name">
<NotPermitted
text="Please login to access this page."
:buttonLink="`/login?redirect-to=/lms/billing/${type}/${name}`"
:buttonLink="`/login?redirect-to=/billing/${type}/${name}`"
/>
</div>
</div>
@@ -340,7 +340,6 @@ const validateAddress = () => {
'Assam',
'Bihar',
'Chhattisgarh',
'Delhi',
'Goa',
'Gujarat',
'Haryana',

View File

@@ -1,92 +0,0 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div>
<FormControl
type="text"
placeholder="Search Participants"
v-model="searchQuery"
@input="participants.reload()"
>
<template #prefix>
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
</template>
</FormControl>
</div>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
<div
v-if="participants.data?.length"
v-for="participant in participantsList"
>
<router-link
:to="{
name: 'Profile',
params: { username: participant.username },
}"
>
<div class="flex shadow rounded-md h-full p-2">
<UserAvatar :user="participant" size="3xl" class="mr-2" />
<div>
<router-link
:to="{
name: 'Profile',
params: { username: participant.username },
}"
>
<div class="text-lg font-semibold mb-2">
{{ participant.full_name }}
</div>
</router-link>
<div class="leading-5" v-for="course in participant.courses">
{{ course }}
</div>
</div>
</div>
</router-link>
</div>
</div>
</template>
<script setup>
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
import { ref, computed } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { Search } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
const searchQuery = ref('')
const participants = createResource({
url: 'lms.lms.api.get_certified_participants',
method: 'GET',
cache: 'certified-participants',
auto: true,
})
const breadcrumbs = computed(() => {
return [{ label: 'Certified Participants', to: '/certified-participants' }]
})
const pageMeta = computed(() => {
return {
title: 'Certified Participants',
description: 'All participants that have been certified.',
}
})
const participantsList = computed(() => {
if (searchQuery.value) {
return participants.data.filter((participant) => {
return participant.full_name
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
})
}
return participants.data
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -41,7 +41,7 @@
>
<div class="flex items-center">
<span
class="h-6 mr-1"
class="mr-1"
:class="{
'avatar-group overlap': course.data.instructors.length > 1,
}"
@@ -51,7 +51,17 @@
:user="instructor"
/>
</span>
<CourseInstructors :instructors="course.data.instructors" />
<span v-if="course.data.instructors.length == 1">
{{ course.data.instructors[0].full_name }}
</span>
<span v-if="course.data.instructors.length == 2">
{{ course.data.instructors[0].first_name }} and
{{ course.data.instructors[1].first_name }}
</span>
<span v-if="course.data.instructors.length > 2">
{{ course.data.instructors[0].first_name }} and
{{ course.data.instructors.length - 1 }} others
</span>
</div>
</div>
<div class="flex mt-3 mb-4 w-fit">
@@ -70,9 +80,14 @@
class="course-description"
></div>
<div class="mt-10">
<CourseOutline :courseName="course.data.name" :showOutline="true" />
<CourseOutline
:courseName="course.data.name"
:showOutline="true"
title="Course Outline"
/>
</div>
<CourseReviews
v-if="course.data.avg_rating"
:courseName="course.data.name"
:avg_rating="course.data.avg_rating"
:membership="course.data.membership"
@@ -94,7 +109,6 @@ import CourseOutline from '@/components/CourseOutline.vue'
import CourseReviews from '@/components/CourseReviews.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { updateDocumentTitle } from '@/utils'
import CourseInstructors from '@/components/CourseInstructors.vue'
const props = defineProps({
courseName: {
@@ -145,12 +159,6 @@ updateDocumentTitle(pageMeta)
padding: revert;
}
.course-description ul {
list-style: disc;
margin: revert;
padding: revert;
}
.avatar-group {
display: inline-flex;
align-items: center;

View File

@@ -5,19 +5,9 @@
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('All Courses'), route: { name: 'Courses' } }]"
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/>
<div class="flex space-x-2">
<FormControl
type="text"
placeholder="Search Course"
v-model="searchQuery"
@input="courses.reload()"
>
<template #prefix>
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
</template>
</FormControl>
<div class="flex">
<router-link
:to="{
name: 'CreateCourse',
@@ -36,10 +26,17 @@
</div>
</header>
<div class="">
<div
v-if="courses.data.length == 0 && courses.list.loading"
class="p-5 text-base text-gray-700"
>
{{ __('Loading Courses...') }}
</div>
<Tabs
v-else
v-model="tabIndex"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
:tabs="makeTabs"
:tabs="tabs"
>
<template #tab="{ tab, selected }">
<div>
@@ -68,8 +65,8 @@
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.current_lesson.split('-')[0],
lessonNumber: course.current_lesson.split('-')[1],
chapterNumber: course.current_lesson.split('.')[0],
lessonNumber: course.current_lesson.split('.')[1],
},
}
: course.membership
@@ -107,75 +104,61 @@
</template>
<script setup>
import {
Breadcrumbs,
Tabs,
Badge,
Button,
FormControl,
createResource,
} from 'frappe-ui'
import { createListResource, Breadcrumbs, Tabs, Badge, Button } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'
import { Plus, Search } from 'lucide-vue-next'
import { Plus } from 'lucide-vue-next'
import { ref, computed, inject } from 'vue'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const searchQuery = ref('')
const courses = createResource({
const courses = createListResource({
type: 'list',
doctype: 'LMS Course',
cache: ['courses', user?.data?.email],
url: 'lms.lms.utils.get_courses',
cache: ['courses', user.data?.email],
auto: true,
})
const tabIndex = ref(0)
let tabs
const tabs = [
{
label: 'All',
courses: computed(() => courses.data?.live || []),
count: computed(() => courses.data?.live?.length),
},
{
label: 'Upcoming',
courses: computed(() => courses.data?.upcoming),
count: computed(() => courses.data?.upcoming?.length),
},
]
const makeTabs = computed(() => {
tabs = []
addToTabs('Live')
addToTabs('New')
addToTabs('Upcoming')
if (user.data) {
addToTabs('Enrolled')
if (
user.data.is_moderator ||
user.data.is_instructor ||
courses.data?.created?.length
) {
addToTabs('Created')
}
if (user.data.is_moderator) {
addToTabs('Under Review')
}
}
return tabs
})
const addToTabs = (label) => {
let courses = getCourses(label.toLowerCase().split(' ').join('_'))
if (user.data) {
tabs.push({
label,
courses: computed(() => courses),
count: computed(() => courses.length),
label: 'Enrolled',
courses: computed(() => courses.data?.enrolled),
count: computed(() => courses.data?.enrolled?.length),
})
}
const getCourses = (type) => {
if (searchQuery.value) {
let query = searchQuery.value.toLowerCase()
return courses.data[type].filter(
(course) =>
course.title.toLowerCase().includes(query) ||
course.short_introduction.toLowerCase().includes(query) ||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
)
if (
user.data.is_moderator ||
user.data.is_instructor ||
courses.data?.created?.length
) {
tabs.push({
label: 'Created',
courses: computed(() => courses.data?.created),
count: computed(() => courses.data?.created?.length),
})
}
if (user.data.is_moderator) {
tabs.push({
label: 'Under Review',
courses: computed(() => courses.data?.under_review),
count: computed(() => courses.data?.under_review?.length),
})
}
return courses.data[type]
}
const pageMeta = computed(() => {

View File

@@ -7,6 +7,19 @@
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div class="flex items-center mt-3 md:mt-0">
<router-link
v-if="courseResource.data"
:to="{
name: 'CourseDetail',
params: { courseName: courseResource.data.name },
}"
>
<Button>
<span>
{{ __('View Course') }}
</span>
</Button>
</router-link>
<Button variant="solid" @click="submitCourse()" class="ml-2">
<span>
{{ __('Save') }}
@@ -86,14 +99,13 @@
:label="__('Preview Video')"
class="mb-4"
/>
<div class="mb-4">
<div>
<div class="mb-1.5 text-xs text-gray-600">
{{ __('Tags') }}
</div>
<div class="flex items-center">
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
v-for="tag in course.tags.split(', ')"
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
>
{{ tag }}
@@ -102,64 +114,30 @@
@click="removeTag(tag)"
/>
</div>
<FormControl
v-model="newTag"
@keyup.enter="updateTags()"
id="tags"
/>
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
</div>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
/>
</div>
<div class="container border-t">
<div class="text-lg font-semibold mt-5 mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-10 mb-4">
<div
v-if="user.data?.is_moderator"
class="flex flex-col space-y-3"
>
<FormControl
type="checkbox"
v-model="course.published"
:label="__('Published')"
/>
<FormControl
v-model="course.published_on"
:label="__('Published On')"
type="date"
class="mb-5"
/>
</div>
<div class="flex flex-col space-y-3">
<FormControl
type="checkbox"
v-model="course.upcoming"
:label="__('Upcoming')"
/>
<FormControl
type="checkbox"
v-model="course.featured"
:label="__('Featured')"
/>
</div>
<div class="flex flex-col space-y-3">
<FormControl
type="checkbox"
v-model="course.disable_self_learning"
:label="__('Disable Self Enrollment')"
/>
<FormControl
type="checkbox"
v-model="course.enable_certification"
:label="__('Completion Certificate')"
/>
</div>
<div class="flex items-center justify-between mb-5">
<FormControl
type="checkbox"
v-model="course.published"
:label="__('Published')"
/>
<FormControl
type="checkbox"
v-model="course.upcoming"
:label="__('Upcoming')"
/>
<FormControl
type="checkbox"
v-model="course.disable_self_learning"
:label="__('Disable Self Enrollment')"
/>
</div>
</div>
<div class="container border-t">
@@ -187,7 +165,8 @@
</div>
</div>
</div>
<div class="border-l pt-5">
<div class="border-l px-5 pt-5">
<!-- <CreateOutline v-if="courseResource.doc" :course="courseResource.doc"/> -->
<CourseOutline
v-if="courseResource.data"
:courseName="courseResource.data.name"
@@ -204,34 +183,20 @@ import {
TextEditor,
Button,
createResource,
createDocumentResource,
FormControl,
FileUploader,
} from 'frappe-ui'
import {
inject,
onMounted,
onBeforeUnmount,
computed,
ref,
reactive,
watch,
} from 'vue'
import {
convertToTitleCase,
showToast,
getFileSize,
updateDocumentTitle,
} from '../utils'
import { inject, onMounted, computed, ref, reactive, watch } from 'vue'
import { convertToTitleCase, showToast, getFileSize } from '../utils'
import Link from '@/components/Controls/Link.vue'
import { FileText, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
const user = inject('$user')
const newTag = ref('')
const router = useRouter()
const instructors = ref([])
const props = defineProps({
courseName: {
@@ -247,44 +212,20 @@ const course = reactive({
course_image: null,
tags: '',
published: false,
published_on: '',
featured: false,
upcoming: false,
disable_self_learning: false,
enable_certification: false,
paid_course: false,
course_price: '',
currency: '',
})
onMounted(() => {
if (
props.courseName == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
if (!user.data?.is_moderator || !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
if (props.courseName !== 'new') {
courseResource.reload()
}
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
submitCourse()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const courseCreationResource = createResource({
@@ -293,10 +234,7 @@ const courseCreationResource = createResource({
return {
doc: {
doctype: 'LMS Course',
image: course.course_image?.file_url || '',
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
image: course.course_image.file_url,
...values,
},
}
@@ -311,10 +249,7 @@ const courseEditResource = createResource({
doctype: 'LMS Course',
name: values.course,
fieldname: {
image: course.course_image?.file_url || '',
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
image: course.course_image.file_url,
...course,
},
}
@@ -332,20 +267,13 @@ const courseResource = createResource({
auto: false,
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (key == 'instructors') {
instructors.value = []
data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (Object.hasOwn(course, key)) course[key] = data[key]
if (Object.hasOwn(course, key)) course[key] = data[key]
})
let checkboxes = [
'published',
'upcoming',
'disable_self_learning',
'paid_course',
'featured',
'enable_certification',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
@@ -353,7 +281,6 @@ const courseResource = createResource({
}
if (data.image) imageResource.reload({ image: data.image })
check_permission()
},
})
@@ -370,6 +297,12 @@ const imageResource = createResource({
},
})
const getTags = computed(() => {
return courseResource.doc?.tags
? courseResource.doc.tags.split(', ')
: tags.value?.split(', ')
})
const submitCourse = () => {
if (courseResource.data) {
courseEditResource.submit(
@@ -395,7 +328,7 @@ const submitCourse = () => {
})
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
showToast(err)
},
})
}
@@ -431,7 +364,7 @@ watch(
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
}
}
@@ -459,21 +392,6 @@ const removeImage = () => {
course.course_image = null
}
const check_permission = () => {
let user_is_instructor = false
if (user.data?.is_moderator) return
instructors.value.forEach((instructor) => {
if (!user_is_instructor && instructor == user.data?.name) {
user_is_instructor = true
}
})
if (!user_is_instructor) {
router.push({ name: 'Courses' })
}
}
const breadcrumbs = computed(() => {
let crumbs = [
{
@@ -493,13 +411,4 @@ const breadcrumbs = computed(() => {
})
return crumbs
})
const pageMeta = computed(() => {
return {
title: 'Create a Course',
description: 'Create or edit a course for your learning system.',
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="">
<div class="grid md:grid-cols-[75%,25%] h-screen">
<div class="grid md:grid-cols-[75%,25%] h-full">
<div class="border-r">
<header
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b overflow-hidden bg-white px-3 py-2.5 sm:px-5"
@@ -43,7 +43,7 @@
<div
v-show="openInstructorEditor"
id="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-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal py-3"
class="py-3"
></div>
</div>
</div>
@@ -52,10 +52,7 @@
<label class="block font-medium text-gray-600 mb-1">
{{ __('Content') }}
</label>
<div
id="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-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal py-3"
></div>
<div id="content" class="py-3"></div>
</div>
</div>
</div>
@@ -69,17 +66,26 @@
</div>
</template>
<script setup>
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
import {
Breadcrumbs,
FormControl,
createResource,
Button,
createDocumentResource,
} from 'frappe-ui'
import { computed, reactive, onMounted, inject, ref, watch } from 'vue'
import EditorJS from '@editorjs/editorjs'
import { createToast } from '../utils'
import LessonPlugins from '@/components/LessonPlugins.vue'
import { getEditorTools } from '../utils'
import { ChevronRight } from 'lucide-vue-next'
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
import { useRouter } from 'vue-router'
const editor = ref(null)
const instructorEditor = ref(null)
const user = inject('$user')
const openInstructorEditor = ref(false)
const router = useRouter()
const props = defineProps({
courseName: {
@@ -97,7 +103,7 @@ const props = defineProps({
})
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
if (!user.data?.is_moderator || !user.data?.is_instructor) {
window.location.href = '/login'
}
editor.value = renderEditor('content')
@@ -108,7 +114,6 @@ const renderEditor = (holder) => {
return new EditorJS({
holder: holder,
tools: getEditorTools(),
autofocus: true,
})
}
@@ -428,117 +433,17 @@ const breadcrumbs = computed(() => {
})
return crumbs
})
const pageMeta = computed(() => {
return {
title: 'Lesson Editor',
description: 'Create and edit lessons for your course',
}
})
updateDocumentTitle(pageMeta)
</script>
<style>
.embed-tool__caption,
.cdx-simple-image__caption {
.embed-tool__caption {
display: none;
}
.ce-toolbar__actions {
right: 108%;
}
.ce-block__content {
max-width: none;
}
.ce-toolbar__content {
max-width: none;
}
.codeBoxHolder {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.codeBoxTextArea {
width: 100%;
min-height: 30px;
padding: 10px;
border-radius: 2px 2px 2px 0;
border: none !important;
outline: none !important;
font: 14px monospace;
}
.codeBoxSelectDiv {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
position: relative;
}
.codeBoxSelectInput {
border-radius: 0 0 20px 2px;
padding: 2px 26px;
padding-top: 0;
padding-right: 0;
text-align: left;
cursor: pointer;
border: none !important;
outline: none !important;
}
.codeBoxSelectDropIcon {
position: absolute !important;
left: 10px !important;
bottom: 0 !important;
width: unset !important;
height: unset !important;
font-size: 16px !important;
}
.codeBoxSelectPreview {
display: none;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
border-radius: 2px;
box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13);
position: absolute;
top: 100%;
margin: 5px 0;
max-height: 30vh;
overflow-x: hidden;
overflow-y: auto;
z-index: 10000;
}
.codeBoxSelectItem {
width: 100%;
padding: 5px 20px;
margin: 0;
cursor: pointer;
}
.codeBoxSelectItem:hover {
opacity: 0.7;
}
.codeBoxSelectedItem {
background-color: lightblue !important;
}
.codeBoxShow {
display: flex !important;
}
.dark {
color: #abb2bf;
background-color: #282c34;
}
.light {
color: #383a42;
background-color: #fafafa;
}
</style>

View File

@@ -50,8 +50,8 @@
</Button>
</div>
</header>
<div v-if="job.data" class="w-3/4 mx-auto">
<div class="p-4">
<div v-if="job.data">
<div class="p-5 sm:p-5">
<div class="flex mb-4">
<img
:src="job.data.company_logo"
@@ -59,44 +59,29 @@
:alt="job.data.company_name"
/>
<div>
<div class="text-2xl font-semibold mb-4">
<div class="text-2xl font-semibold mb-2">
{{ job.data.job_title }}
</div>
<div class="grid grid-cols-3 gap-8">
<div class="grid grid-cols-1 gap-2">
<div class="flex items-center space-x-2">
<Building2 class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.company_name }}</span>
</div>
<div class="flex items-center space-x-2">
<div>
{{ __('posted by') }}
<span class="font-medium">{{ job.data.company_name }}</span>
{{ __('on') }}
<span class="font-medium">{{
dayjs(job.data.creation).format('DD MMM YYYY')
}}</span>
</div>
<div class="flex items-center mt-2">
<Badge :label="job.data.type" theme="green" size="lg" />
<Badge
:label="job.data.location"
theme="gray"
size="lg"
class="ml-4"
>
<template #prefix>
<MapPin class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.location }}</span>
</div>
</div>
<div class="grid grid-cols-1 gap-2">
<div class="flex items-center space-x-2">
<ClipboardType class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.type }}</span>
</div>
<div class="flex items-center space-x-2">
<CalendarDays class="h-4 w-4 stroke-1.5" />
<span>{{
dayjs(job.data.creation).format('DD MMM YYYY')
}}</span>
</div>
</div>
<div class="grid grid-cols-1 h-fit">
<div
v-if="applicationCount.data"
class="flex items-center space-x-2"
>
<SquareUserRound class="h-4 w-4 stroke-1.5" />
<span
>{{ applicationCount.data }}
{{ __('applications received') }}</span
>
</div>
</div>
</template>
</Badge>
</div>
</div>
</div>
@@ -114,19 +99,10 @@
</div>
</template>
<script setup>
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
import { inject, ref, computed } from 'vue'
import { updateDocumentTitle } from '@/utils'
import { Badge, Button, Breadcrumbs, createResource } from 'frappe-ui'
import { inject, ref, onMounted } from 'vue'
import { MapPin, SendHorizonal, Pencil } from 'lucide-vue-next'
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
import {
MapPin,
SendHorizonal,
Pencil,
Building2,
CalendarDays,
ClipboardType,
SquareUserRound,
} from 'lucide-vue-next'
const user = inject('$user')
const dayjs = inject('$dayjs')
@@ -150,7 +126,6 @@ const job = createResource({
if (user.data?.name) {
jobApplication.submit()
}
applicationCount.submit()
},
})
@@ -167,18 +142,6 @@ const jobApplication = createResource({
},
})
const applicationCount = createResource({
url: 'frappe.client.get_count',
makeParams(values) {
return {
doctype: 'LMS Job Application',
filters: {
job: job.data?.name,
},
}
},
})
const openApplicationModal = () => {
showApplicationModal.value = true
}
@@ -186,13 +149,4 @@ const openApplicationModal = () => {
const redirectToLogin = (job) => {
window.location.href = `/login?redirect-to=/job-openings/${job}`
}
const pageMeta = computed(() => {
return {
title: job.data?.job_title,
description: job.data?.description,
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -26,9 +26,9 @@
</router-link>
</div>
</header>
<div v-if="jobs.data?.length">
<div class="divide-y lg:w-3/4 mx-auto p-5">
<div v-for="job in jobs.data">
<div v-if="jobs.data">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 p-5">
<div v-if="jobs.data.length" v-for="job in jobs.data">
<router-link
:to="{
name: 'JobDetail',
@@ -41,17 +41,13 @@
</div>
</div>
</div>
<div v-else class="text-gray-700 italic p-5 w-fit mx-auto">
{{ __('No jobs posted') }}
</div>
</div>
</template>
<script setup>
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
import { Plus } from 'lucide-vue-next'
import { inject, computed } from 'vue'
import { inject } from 'vue'
import JobCard from '@/components/JobCard.vue'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
@@ -60,13 +56,4 @@ const jobs = createResource({
cache: ['jobs'],
auto: true,
})
const pageMeta = computed(() => {
return {
title: 'Jobs',
description: 'An open job board for the community',
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -5,10 +5,10 @@
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div class="grid md:grid-cols-[70%,30%] h-screen">
<div class="grid md:grid-cols-[70%,30%] h-full">
<div
v-if="lesson.data.no_preview"
class="border-r text-center pt-10 px-5 md:px-0 pb-10"
class="border-r-2 text-center pt-10 px-5 md:px-0 pb-10"
>
<p class="mb-4">
{{
@@ -18,18 +18,14 @@
}}
</p>
<router-link
v-if="user.data"
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
>
<Button variant="solid">
{{ __('Start Learning') }}
</Button>
</router-link>
<Button v-else @click="redirectToLogin()">
{{ __('Login') }}
</Button>
</div>
<div v-else class="border-r container pt-5 pb-10 px-5">
<div v-else class="border-r-2 container pt-5 pb-10 px-5">
<div class="flex flex-col md:flex-row md:items-center justify-between">
<div class="text-3xl font-semibold">
{{ lesson.data.title }}
@@ -90,23 +86,12 @@
</span>
</Button>
</router-link>
<router-link
v-else
:to="{
name: 'CourseDetail',
params: { courseName: courseName },
}"
>
<Button>
{{ __('Back to Course') }}
</Button>
</router-link>
</div>
</div>
<div class="flex items-center mt-2">
<span
class="h-6 mr-1"
class="mr-1"
:class="{
'avatar-group overlap': lesson.data.instructors.length > 1,
}"
@@ -116,7 +101,17 @@
:user="instructor"
/>
</span>
<CourseInstructors :instructors="lesson.data.instructors" />
<span v-if="lesson.data.instructors.length == 1">
{{ lesson.data.instructors[0].full_name }}
</span>
<span v-if="lesson.data.instructors.length == 2">
{{ lesson.data.instructors[0].first_name }} and
{{ lesson.data.instructors[1].first_name }}
</span>
<span v-if="lesson.data.instructors.length > 2">
{{ lesson.data.instructors[0].first_name }} and
{{ lesson.data.instructors.length - 1 }} others
</span>
</div>
<div
v-if="
@@ -157,7 +152,7 @@
</div>
<div class="mt-20">
<Discussions
v-if="allowDiscussions"
v-if="allowDiscussions()"
:title="'Questions'"
:doctype="'Course Lesson'"
:docname="lesson.data.name"
@@ -166,50 +161,45 @@
</div>
</div>
<div class="sticky top-10">
<div class="bg-gray-50 py-5 px-2 border-b">
<div class="bg-gray-50 p-5 border-b-2">
<div class="text-lg font-semibold">
{{ lesson.data.course_title }}
</div>
<div v-if="user && lesson.data.membership" class="text-sm mt-3">
{{ Math.ceil(lessonProgress) }}% {{ __('completed') }}
{{ Math.ceil(lesson.data.membership.progress) }}% completed
</div>
<ProgressBar
<div
v-if="user && lesson.data.membership"
:progress="lessonProgress"
/>
class="w-full bg-gray-200 rounded-full h-1 my-2"
>
<div
class="bg-gray-900 h-1 rounded-full"
:style="{
width: Math.ceil(lesson.data.membership.progress) + '%',
}"
></div>
</div>
</div>
<CourseOutline
:courseName="courseName"
:key="chapterNumber"
:getProgress="lesson.data.membership ? true : false"
/>
<CourseOutline :courseName="courseName" :key="chapterNumber" />
</div>
</div>
</div>
</template>
<script setup>
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
import { computed, watch, ref, inject, createApp } from 'vue'
import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRoute } from 'vue-router'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue'
import { getEditorTools, updateDocumentTitle } from '../utils'
import { getEditorTools } from '../utils'
import EditorJS from '@editorjs/editorjs'
import LessonContent from '@/components/LessonContent.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user')
const route = useRoute()
const allowDiscussions = ref(false)
const editor = ref(null)
const instructorEditor = ref(null)
const lessonProgress = ref(0)
const timer = ref(0)
let timerInterval
let editor, instructorEditor
const props = defineProps({
courseName: {
@@ -226,10 +216,6 @@ const props = defineProps({
},
})
onMounted(() => {
startTimer()
})
const lesson = createResource({
url: 'lms.lms.utils.get_lesson',
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
@@ -242,29 +228,25 @@ const lesson = createResource({
},
auto: true,
onSuccess(data) {
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (data.membership)
current_lesson.submit({
name: data.membership.name,
lesson_name: data.name,
})
markProgress(data)
if (data.content) editor = renderEditor('editor', data.content)
if (data.instructor_content?.blocks?.length)
instructorEditor.value = renderEditor(
instructorEditor = renderEditor(
'instructor-content',
data.instructor_content
)
editor.value?.isReady.then(() => {
checkIfDiscussionsAllowed()
})
if (!editor.value && data.body) {
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
const hasQuiz = quizRegex.test(data.body)
if (!hasQuiz) allowDiscussions.value = true
}
},
})
const renderEditor = (holder, content) => {
// empty the holder
if (document.getElementById(holder))
document.getElementById(holder).innerHTML = ''
document.getElementById(holder).innerHTML = ''
return new EditorJS({
holder: holder,
tools: getEditorTools(),
@@ -274,12 +256,22 @@ const renderEditor = (holder, content) => {
})
}
const markProgress = () => {
if (user.data && !lesson.data?.progress) {
progress.submit()
}
const markProgress = (data) => {
if (user.data && !data.progress) progress.submit()
}
const current_lesson = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'LMS Enrollment',
name: values.name,
fieldname: 'current_lesson',
value: values.lesson_name,
}
},
})
const progress = createResource({
url: 'lms.lms.doctype.course_lesson.course_lesson.save_progress',
makeParams() {
@@ -288,9 +280,6 @@ const progress = createResource({
course: props.courseName,
}
},
onSuccess(data) {
lessonProgress.value = data
},
})
const breadcrumbs = computed(() => {
@@ -319,48 +308,21 @@ watch(
[newChapterNumber, newLessonNumber],
[oldChapterNumber, oldLessonNumber]
) => {
if (newChapterNumber || newLessonNumber) {
editor.value = null
instructorEditor.value = null
allowDiscussions.value = false
if (newChapterNumber && newLessonNumber) {
lesson.submit({
chapter: newChapterNumber,
lesson: newLessonNumber,
})
clearInterval(timerInterval)
timer.value = 0
startTimer()
}
}
)
const startTimer = () => {
timerInterval = setInterval(() => {
timer.value++
if (timer.value == 30) {
clearInterval(timerInterval)
markProgress()
}
}, 1000)
}
onBeforeUnmount(() => {
clearInterval(timerInterval)
})
const checkIfDiscussionsAllowed = () => {
let quizPresent = false
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
if (block.type === 'quiz') quizPresent = true
})
if (
!quizPresent &&
(lesson.data?.membership ||
user.data?.is_moderator ||
user.data?.is_instructor)
const allowDiscussions = () => {
return (
lesson.data?.membership ||
user.data?.is_moderator ||
user.data?.is_instructor
)
allowDiscussions.value = true
}
const allowEdit = () => {
@@ -374,19 +336,6 @@ const allowInstructorContent = () => {
if (lesson.data?.instructors.includes(user.data?.name)) return true
return false
}
const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
}
const pageMeta = computed(() => {
return {
title: lesson.data?.title,
description: lesson.data?.course,
}
})
updateDocumentTitle(pageMeta)
</script>
<style>
.avatar-group {
@@ -440,101 +389,11 @@ updateDocumentTitle(pageMeta)
font-weight: 500;
}
.codex-editor__redactor {
padding-bottom: 0 !important;
}
.embed-tool__caption {
display: none;
}
.ce-block__content {
max-width: unset;
}
.codeBoxHolder {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.codeBoxTextArea {
width: 100%;
min-height: 30px;
padding: 10px;
border-radius: 2px 2px 2px 0;
border: none !important;
outline: none !important;
font: 14px monospace;
}
.codeBoxSelectDiv {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
position: relative;
}
.codeBoxSelectInput {
border-radius: 0 0 20px 2px;
padding: 2px 26px;
padding-top: 0;
padding-right: 0;
text-align: left;
cursor: pointer;
border: none !important;
outline: none !important;
}
.codeBoxSelectDropIcon {
position: absolute !important;
left: 10px !important;
bottom: 0 !important;
width: unset !important;
height: unset !important;
font-size: 16px !important;
}
.codeBoxSelectPreview {
display: none;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
border-radius: 2px;
box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13);
position: absolute;
top: 100%;
margin: 5px 0;
max-height: 30vh;
overflow-x: hidden;
overflow-y: auto;
z-index: 10000;
}
.codeBoxSelectItem {
width: 100%;
padding: 5px 20px;
margin: 0;
cursor: pointer;
}
.codeBoxSelectItem:hover {
opacity: 0.7;
}
.codeBoxSelectedItem {
background-color: lightblue !important;
}
.codeBoxShow {
display: flex !important;
}
.dark {
color: #abb2bf;
background-color: #282c34;
}
.light {
color: #383a42;
background-color: #fafafa;
}
</style>

View File

@@ -1,167 +0,0 @@
<template>
<header
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div class="flex items-center space-x-2">
<Button
@click="markAllAsRead.submit"
:loading="markAllAsRead.loading"
v-if="activeTab === 'Unread' && unReadNotifications.data?.length > 0"
>
{{ __('Mark all as read') }}
</Button>
<TabButtons
class="inline-block"
:buttons="[{ label: 'Unread', active: true }, { label: 'Read' }]"
v-model="activeTab"
/>
</div>
</header>
<div class="w-3/4 mx-auto px-5 pt-6 divide-y">
<div
v-if="notifications?.length"
v-for="log in notifications"
class="flex items-center py-2 justify-between"
>
<div class="flex items-center">
<UserAvatar :user="allUsers.data[log.from_user]" class="mr-2" />
<div class="notification" v-html="log.subject"></div>
</div>
<div class="flex items-center space-x-2">
<Link
v-if="log.link"
:to="log.link"
@click="markAsRead.submit({ name: log.name })"
class="text-gray-600 font-medium text-sm hover:text-gray-700"
>
{{ __('View') }}
</Link>
<Tooltip :text="__('Mark as read')">
<Button
variant="ghost"
v-if="!log.read"
@click="markAsRead.submit({ name: log.name })"
>
<template #icon>
<X class="h-4 w-4 text-gray-700 stroke-1.5" />
</template>
</Button>
</Tooltip>
</div>
</div>
<div v-else class="text-gray-600">
{{ __('Nothing to see here.') }}
</div>
</div>
</template>
<script setup>
import {
createListResource,
createResource,
Breadcrumbs,
Link,
TabButtons,
Button,
Tooltip,
} from 'frappe-ui'
import { computed, inject, ref, onMounted } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter } from 'vue-router'
import { X } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const socket = inject('$socket')
const allUsers = inject('$allUsers')
const activeTab = ref('Unread')
const router = useRouter()
onMounted(() => {
if (!user.data) router.push({ name: 'Courses' })
socket.on('publish_lms_notifications', (data) => {
unReadNotifications.reload()
})
})
const notifications = computed(() => {
return activeTab.value === 'Unread'
? unReadNotifications.data
: readNotifications.data
})
const unReadNotifications = createListResource({
doctype: 'Notification Log',
fields: ['subject', 'from_user', 'link', 'read', 'name'],
filters: {
for_user: user.data?.name,
read: 0,
},
orderBy: 'creation desc',
auto: true,
cache: 'Unread Notifications',
})
const readNotifications = createListResource({
doctype: 'Notification Log',
fields: ['subject', 'from_user', 'link', 'read', 'name'],
filters: {
for_user: user.data?.name,
read: 1,
},
orderBy: 'creation desc',
auto: true,
cache: 'Read Notifications',
})
const markAsRead = createResource({
url: 'lms.lms.api.mark_as_read',
makeParams(values) {
return {
name: values.name,
}
},
onSuccess(data) {
unReadNotifications.reload()
readNotifications.reload()
},
})
const markAllAsRead = createResource({
url: 'lms.lms.api.mark_all_as_read',
onSuccess(data) {
unReadNotifications.reload()
readNotifications.reload()
},
})
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Notifications',
route: {
name: 'Notifications',
},
},
]
return crumbs
})
const pageMeta = computed(() => {
return {
title: 'Notifications',
description: 'All your notifications in one place.',
}
})
updateDocumentTitle(pageMeta)
</script>
<style>
.notification strong {
font-weight: 400;
}
.notification b {
font-weight: 400;
}
</style>

View File

@@ -1,220 +1 @@
<template>
<NoPermission v-if="!$user.data" />
<div v-else-if="profile.data">
<header
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div class="group relative h-[130px] w-full">
<img
v-if="profile.data.cover_image"
:src="profile.data.cover_image"
class="h-[130px] w-full object-cover object-center"
/>
<div
v-else
:class="{ 'bg-gray-100': !profile.data.cover_image }"
class="h-[130px] w-full"
></div>
<div
class="absolute bottom-0 left-1/2 mb-4 flex -translate-x-1/2 space-x-2 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
v-if="isSessionUser()"
>
<EditCoverImage
@select="(imageUrl) => coverImage.submit({ url: imageUrl })"
>
<template v-slot="{ togglePopover }">
<Button variant="outline" @click="togglePopover()">
<template #prefix>
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
</template>
{{ __('Edit') }}
</Button>
</template>
</EditCoverImage>
</div>
</div>
<div class="mx-auto -mt-10 md:-mt-4 max-w-4xl translate-x-0 px-5">
<div class="flex flex-col md:flex-row items-center">
<div>
<img
v-if="profile.data.user_image"
:src="profile.data.user_image"
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
/>
<UserAvatar
v-else
:user="profile.data"
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
/>
</div>
<div class="ml-6">
<h2 class="mt-2 text-3xl font-semibold text-gray-900">
{{ profile.data.full_name }}
</h2>
<div class="mt-2 text-base text-gray-700">
{{ profile.data.headline }}
</div>
</div>
<Button
v-if="isSessionUser()"
class="mt-3 sm:mt-0 md:ml-auto"
@click="editProfile()"
>
<template #prefix>
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
</template>
{{ __('Edit Profile') }}
</Button>
</div>
<div class="mb-4 mt-6">
<TabButtons
class="inline-block"
:buttons="getTabButtons()"
v-model="activeTab"
/>
</div>
<router-view :profile="profile" />
</div>
</div>
<EditProfile
v-model="showProfileModal"
v-model:reloadProfile="profile"
:profile="profile"
/>
</template>
<script setup>
import { Breadcrumbs, createResource, Button, TabButtons } from 'frappe-ui'
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
import { sessionStore } from '@/stores/session'
import { Edit } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRoute, useRouter } from 'vue-router'
import NoPermission from '@/components/NoPermission.vue'
import { convertToTitleCase, updateDocumentTitle } from '@/utils'
import EditProfile from '@/components/Modals/EditProfile.vue'
import EditCoverImage from '@/components/Modals/EditCoverImage.vue'
const { user } = sessionStore()
const $user = inject('$user')
const route = useRoute()
const router = useRouter()
const activeTab = ref('')
const showProfileModal = ref(false)
const props = defineProps({
username: {
type: String,
required: true,
},
})
onMounted(() => {
if ($user.data) profile.reload()
setActiveTab()
})
const profile = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'User',
filters: {
username: props.username,
},
}
},
})
const coverImage = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'User',
name: profile.data?.name,
fieldname: 'cover_image',
value: values.url,
}
},
onSuccess() {
profile.reload()
},
})
const setActiveTab = () => {
let fragments = route.path.split('/')
let sections = ['certificates', 'roles', 'evaluations']
sections.forEach((section) => {
if (fragments.includes(section)) {
activeTab.value = convertToTitleCase(section)
}
})
if (!activeTab.value) activeTab.value = 'About'
}
watchEffect(() => {
if (activeTab.value) {
let route = {
About: { name: 'ProfileAbout' },
Certificates: { name: 'ProfileCertificates' },
Roles: { name: 'ProfileRoles' },
Evaluations: { name: 'ProfileEvaluator' },
}[activeTab.value]
router.push(route)
}
})
watch(
() => props.username,
() => {
profile.reload()
}
)
const editProfile = () => {
showProfileModal.value = true
}
const isSessionUser = () => {
return $user.data?.email === profile.data?.email
}
const getTabButtons = () => {
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
if (isSessionUser() && $user.data?.is_evaluator)
buttons.push({ label: 'Evaluations' })
return buttons
}
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'People',
},
{
label: profile.data?.full_name,
route: {
name: 'Profile',
params: {
username: user.doc?.username,
},
},
},
]
return crumbs
})
const pageMeta = computed(() => {
return {
title: profile.data?.full_name,
description: profile.data?.headline,
}
})
updateDocumentTitle(pageMeta)
</script>
<template></template>

View File

@@ -1,155 +0,0 @@
<template>
<div class="mt-7 mb-10">
<h2 class="mb-3 text-lg font-semibold text-gray-900">
{{ __('About') }}
</h2>
<div
v-if="profile.data.bio"
v-html="profile.data.bio"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
></div>
<div v-else class="text-gray-700 text-sm italic">
{{ __('No introduction') }}
</div>
</div>
<div class="mt-7 mb-10" v-if="badges.data?.length">
<h2 class="mb-3 text-lg font-semibold text-gray-900">
{{ __('Achievements') }}
</h2>
<div class="grid grid-cols-5 gap-4">
<div v-for="badge in badges.data">
<Popover trigger="hover" :leaveDelay="Number(0.01)">
<template #target>
<div class="relative">
<img
:src="badge.badge_image"
:alt="badge.badge"
class="h-[80px]"
/>
<div
v-if="badge.count > 1"
class="flex items-end bg-gray-100 p-2 text-xs font-semibold rounded-full absolute right-0 bottom-0"
>
<span>
<X class="w-3 h-3" />
</span>
{{ badge.count }}
</div>
</div>
</template>
<template #body-main>
<div class="w-[250px] text-base">
<img
:src="badge.badge_image"
:alt="badge.badge"
class="bg-gray-100 rounded-t-md"
/>
<div class="p-5">
<div class="text-2xl font-semibold mb-2">
{{ badge.badge }}
</div>
<div class="leading-5 mb-4">
{{ badge.badge_description }}
</div>
<div class="flex flex-col mb-4">
<span class="text-xs text-gray-700 font-medium mb-1">
{{ __('Issued on') }}:
</span>
{{ dayjs(badge.issued_on).format('DD MMM YYYY') }}
</div>
<div class="flex flex-col">
<span class="text-xs text-gray-700 font-medium mb-1">
{{ __('Share on') }}:
</span>
<div class="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
@click="shareOnSocial(badge, 'LinkedIn')"
>
<template #prefix>
<LinkedinIcon class="h-3 w-3 text-gray-700" />
</template>
<span class="text-xs">
{{ __('LinkedIn') }}
</span>
</Button>
<Button
variant="outline"
size="sm"
@click="shareOnSocial(badge, 'Twitter')"
>
<template #prefix>
<Twitter class="h-3 w-3 text-gray-700" />
</template>
<span class="text-xs">
{{ __('Twitter') }}
</span>
</Button>
</div>
</div>
</div>
</div>
</template>
</Popover>
</div>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue'
import { createResource, Popover, Button } from 'frappe-ui'
import { X, LinkedinIcon, Twitter } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
const dayjs = inject('$dayjs')
const { branding } = sessionStore()
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
const badges = createResource({
url: 'frappe.client.get_list',
params: {
doctype: 'LMS Badge Assignment',
fields: ['name', 'badge', 'badge_image', 'badge_description', 'issued_on'],
filters: {
member: props.profile.data.name,
},
},
auto: true,
transform(data) {
let finalBadges = []
let groupedBadges = Object.groupBy(data, ({ badge }) => badge)
for (let badge in groupedBadges) {
let badgeData = groupedBadges[badge][0]
badgeData.count = groupedBadges[badge].length
finalBadges.push(badgeData)
}
return finalBadges
},
})
const shareOnSocial = (badge, medium) => {
let shareUrl
const url = encodeURIComponent(
`${window.location.origin}/lms/badges/${badge.badge}/${props.profile.data?.email}`
)
const summary = `I am happy to announce that I earned the ${
badge.badge
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
branding.data?.brand_name
}.`
if (medium == 'LinkedIn')
shareUrl = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&text=${summary}`
else if (medium == 'Twitter')
shareUrl = `https://twitter.com/intent/tweet?text=${summary}&url=${url}`
window.open(shareUrl, '_blank')
}
</script>

View File

@@ -1,51 +0,0 @@
<template>
<div class="mt-7 mb-10">
<h2 class="mb-3 text-lg font-semibold text-gray-900">
{{ __('Certificates') }}
</h2>
<div class="grid grod-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="certificate in certificates.data"
:key="certificate.name"
class="bg-white shadow rounded-lg p-3 cursor-pointer"
@click="openCertificate(certificate)"
>
<div class="font-medium leading-5">
{{ certificate.course_title }}
</div>
<div class="mt-2">
<span class="text-xs text-gray-700"> {{ __('issued on') }}: </span>
{{ dayjs(certificate.issue_date).format('DD MMM YYYY') }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { createResource } from 'frappe-ui'
import { inject } from 'vue'
const dayjs = inject('$dayjs')
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
const certificates = createResource({
url: 'lms.lms.api.get_certificates',
params: {
member: props.profile.data.name,
},
auto: true,
})
const openCertificate = (certificate) => {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
certificate.name
}&format=${encodeURIComponent(certificate.template)}`
)
}
</script>

View File

@@ -1,323 +0,0 @@
<template>
<div class="mt-7 mb-20">
<h2 class="mb-4 text-lg font-semibold text-gray-900">
{{ __('My availability') }}
</h2>
<div class="">
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-gray-700 mb-4"
>
<div>
{{ __('Day') }}
</div>
<div>
{{ __('Start Time') }}
</div>
<div>
{{ __('End Time') }}
</div>
</div>
<div
v-if="evaluator.data"
v-for="slot in evaluator.data.slots.schedule"
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
>
<FormControl
type="select"
:options="days"
v-model="slot.day"
@focusout.stop="update(slot.name, 'day', slot.day)"
/>
<FormControl
type="time"
v-model="slot.start_time"
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
/>
<FormControl
type="time"
v-model="slot.end_time"
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
/>
<X
@click="deleteRow(slot.name)"
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-red-100 hidden group-hover:block"
/>
</div>
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
v-show="showSlotsTemplate"
>
<FormControl
type="select"
:options="days"
v-model="newSlot.day"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.start_time"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.end_time"
@focusout.stop="add()"
/>
</div>
<Button @click="showSlotsTemplate = 1">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5 text-gray-700" />
</template>
{{ __('Add Slot') }}
</Button>
</div>
<div class="my-10">
<h2 class="mb-4 text-lg font-semibold text-gray-900">
{{ __('I am unavailable') }}
</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<FormControl
type="date"
:label="__('From')"
v-model="from"
@blur="
() => {
updateUnavailability.submit({
field: 'unavailable_from',
value: from,
})
}
"
/>
<FormControl
type="date"
:label="__('To')"
v-model="to"
@blur="
() => {
updateUnavailability.submit({
field: 'unavailable_to',
value: to,
})
}
"
/>
</div>
</div>
<div>
<h2 class="mb-4 text-lg font-semibold text-gray-900">
{{ __('My calendar') }}
</h2>
<div
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized"
class="flex items-center bg-green-100 text-green-900 text-sm p-1 rounded-md mb-4 w-fit"
>
<Check class="h-4 w-4 stroke-1.5 mr-2" />
{{ __('Your calendar is set.') }}
</div>
<Button @click="() => authorizeCalendar.submit()">
{{ __('Authorize Google Calendar Access') }}
</Button>
</div>
</div>
</template>
<script setup>
import { createResource, FormControl, Button } from 'frappe-ui'
import { computed, reactive, ref, onMounted, inject } from 'vue'
import { showToast, convertToTitleCase } from '@/utils'
import { Plus, X, Check } from 'lucide-vue-next'
const user = inject('$user')
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
onMounted(() => {
if (user.data?.name !== props.profile.data?.name) {
window.location.href = `/user/${props.profile.data?.username}`
}
})
const showSlotsTemplate = ref(0)
const from = ref(null)
const to = ref(null)
const newSlot = reactive({
day: '',
start_time: '',
end_time: '',
})
const evaluator = createResource({
url: 'lms.lms.api.get_evaluator_details',
params: {
evaluator: props.profile.data?.name,
},
auto: true,
onSuccess(data) {
if (data.slots.unavailable_from) from.value = data.slots.unavailable_from
if (data.slots.unavailable_to) to.value = data.slots.unavailable_to
},
})
const createSlot = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Evaluator Schedule',
parent: evaluator.data?.slots.name,
parentfield: 'schedule',
parenttype: 'Course Evaluator',
...newSlot,
},
}
},
onSuccess() {
showToast('Success', 'Slot added successfully', 'check')
evaluator.reload()
showSlotsTemplate.value = 0
newSlot.day = ''
newSlot.start_time = ''
newSlot.end_time = ''
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
})
const updateSlot = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Evaluator Schedule',
name: values.name,
fieldname: values.field,
value: values.value,
}
},
onSuccess() {
showToast('Success', 'Availability updated successfully', 'check')
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
})
const deleteSlot = createResource({
url: 'frappe.client.delete',
makeParams(values) {
return {
doctype: 'Evaluator Schedule',
name: values.name,
}
},
onSuccess() {
showToast('Success', 'Slot deleted successfully', 'check')
evaluator.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
})
const updateUnavailability = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Course Evaluator',
name: evaluator.data?.slots.name,
fieldname: values.field,
value: values.value,
}
},
onSuccess() {
showToast('Success', 'Unavailability updated successfully', 'check')
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
})
const update = (name, field, value) => {
updateSlot.submit(
{
name,
field,
value,
},
{
validate() {
if (!value) {
return `Please enter a value for ${convertToTitleCase(field)}`
}
},
}
)
}
const add = () => {
if (!newSlot.day || !newSlot.start_time || !newSlot.end_time) {
return
}
createSlot.submit()
}
const deleteRow = (name) => {
deleteSlot.submit({ name })
}
const authorizeCalendar = createResource({
url: 'frappe.integrations.doctype.google_calendar.google_calendar.authorize_access',
makeParams() {
return {
g_calendar: evaluator.data?.calendar,
reauthorize: 1,
}
},
onSuccess(data) {
window.open(data.url)
},
})
const days = computed(() => {
return [
{
label: 'Monday',
value: 'Monday',
},
{
label: 'Tuesday',
value: 'Tuesday',
},
{
label: 'Wednesday',
value: 'Wednesday',
},
{
label: 'Thursday',
value: 'Thursday',
},
{
label: 'Friday',
value: 'Friday',
},
{
label: 'Saturday',
value: 'Saturday',
},
{
label: 'Sunday',
value: 'Sunday',
},
]
})
</script>

View File

@@ -1,98 +0,0 @@
<template>
<div class="mt-7">
<h2 class="mb-3 text-lg font-semibold text-gray-900">
{{ __('Settings') }}
</h2>
<div
class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5"
>
<FormControl
:label="__('Moderator')"
v-model="moderator"
type="checkbox"
@change.stop="changeRole('moderator')"
/>
<FormControl
:label="__('Course Creator')"
v-model="course_creator"
type="checkbox"
@change.stop="changeRole('course_creator')"
/>
<FormControl
:label="__('Evaluator')"
v-model="batch_evaluator"
type="checkbox"
@change.stop="changeRole('batch_evaluator')"
/>
<FormControl
:label="__('Student')"
v-model="lms_student"
type="checkbox"
@change.stop="changeRole('lms_student')"
/>
</div>
</div>
</template>
<script setup>
import { FormControl, createResource } from 'frappe-ui'
import { ref } from 'vue'
import { showToast, convertToTitleCase } from '@/utils'
const moderator = ref(false)
const course_creator = ref(false)
const batch_evaluator = ref(false)
const lms_student = ref(false)
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
const roles = createResource({
url: 'lms.lms.utils.get_roles',
makeParams(values) {
return {
name: props.profile.data?.name,
}
},
auto: true,
onSuccess(data) {
let roles = [
'moderator',
'course_creator',
'batch_evaluator',
'lms_student',
]
for (let role of roles) {
if (data[role]) eval(role).value = true
}
},
})
const updateRole = createResource({
url: 'lms.overrides.user.save_role',
makeParams(values) {
return {
user: props.profile.data?.name,
role: values.role,
value: values.value,
}
},
})
const changeRole = (role) => {
updateRole.submit(
{
role: convertToTitleCase(role.split('_').join(' ')),
value: eval(role).value,
},
{
onSuccess(data) {
showToast('Success', 'Role updated successfully', 'check')
},
}
)
}
</script>

View File

@@ -6,17 +6,17 @@
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div v-if="chartDetails.data" class="p-5">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4">
<div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3">
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />
</div>
<div>
<div class="text-xl font-semibold mb-1">
{{ formatNumber(chartDetails.data.courses) }}
{{ chartDetails.data.courses }}
</div>
<div class="text-gray-700">
{{ __('Courses') }}
{{ __('Published Courses') }}
</div>
</div>
</div>
@@ -26,10 +26,10 @@
</div>
<div>
<div class="text-xl font-semibold mb-1">
{{ formatNumber(chartDetails.data.users) }}
{{ chartDetails.data.users }}
</div>
<div class="text-gray-700">
{{ __('Signups') }}
{{ __('Total Signups') }}
</div>
</div>
</div>
@@ -39,10 +39,10 @@
</div>
<div>
<div class="text-xl font-semibold mb-1">
{{ formatNumber(chartDetails.data.enrollments) }}
{{ chartDetails.data.enrollments }}
</div>
<div class="text-gray-700">
{{ __('Enrollments') }}
{{ __('Enrolled Users') }}
</div>
</div>
</div>
@@ -52,10 +52,10 @@
</div>
<div>
<div class="text-xl font-semibold mb-1">
{{ formatNumber(chartDetails.data.completions) }}
{{ chartDetails.data.completions }}
</div>
<div class="text-gray-700">
{{ __('Completions') }}
{{ __('Courses Completed') }}
</div>
</div>
</div>
@@ -65,10 +65,10 @@
</div>
<div>
<div class="text-xl font-semibold mb-1">
{{ formatNumber(chartDetails.data.lesson_completions) }}
{{ chartDetails.data.lesson_completions }}
</div>
<div class="text-gray-700">
{{ __('Milestones') }}
{{ __('Lessons Completed') }}
</div>
</div>
</div>
@@ -109,8 +109,6 @@
<script setup>
import { createResource, Breadcrumbs } from 'frappe-ui'
import { computed, inject } from 'vue'
import { updateDocumentTitle } from '@/utils'
import { formatNumber } from '@/utils'
import { Line, Pie } from 'vue-chartjs'
import {
Chart as ChartJS,
@@ -198,7 +196,7 @@ const courseCompletion = createResource({
const signupChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Signups'
options.plugins.title.text = 'New Signups'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
@@ -214,7 +212,7 @@ const signupChartOptions = () => {
const enrollmentChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Enrollments'
options.plugins.title.text = 'Course Enrollments'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
@@ -230,7 +228,7 @@ const enrollmentChartOptions = () => {
const lessonChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Milestones'
options.plugins.title.text = 'Lesson Completion'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
@@ -246,7 +244,7 @@ const lessonChartOptions = () => {
const courseChartOptions = () => {
let options = chartOptions(true)
options.plugins.title.text = 'Completions'
options.plugins.title.text = 'Course Completion'
options.backgroundColor = ['#4563f0', '#f683ae']
return options
}
@@ -306,13 +304,4 @@ const chartOptions = (isPie) => {
},
}
}
const pageMeta = computed(() => {
return {
title: 'Statistics',
description: 'Statistics of the platform',
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -56,33 +56,10 @@ const routes = [
component: () => import('@/pages/Statistics.vue'),
},
{
path: '/user/:username',
path: '/user/:userName',
name: 'Profile',
component: () => import('@/pages/Profile.vue'),
props: true,
redirect: { name: 'ProfileAbout' },
children: [
{
name: 'ProfileAbout',
path: '',
component: () => import('@/pages/ProfileAbout.vue'),
},
{
name: 'ProfileCertificates',
path: 'certificates',
component: () => import('@/pages/ProfileCertificates.vue'),
},
{
name: 'ProfileRoles',
path: 'roles',
component: () => import('@/pages/ProfileRoles.vue'),
},
{
name: 'ProfileEvaluator',
path: 'evaluations',
component: () => import('@/pages/ProfileEvaluator.vue'),
},
],
},
{
path: '/job-openings',
@@ -125,22 +102,6 @@ const routes = [
component: () => import('@/pages/AssignmentSubmission.vue'),
props: true,
},
{
path: '/certified-participants',
name: 'CertifiedParticipants',
component: () => import('@/pages/CertifiedParticipants.vue'),
},
{
path: '/notifications',
name: 'Notifications',
component: () => import('@/pages/Notifications.vue'),
},
{
path: '/badges/:badgeName/:email',
name: 'Badge',
component: () => import('@/pages/Badge.vue'),
props: true,
},
]
let router = createRouter({
@@ -149,21 +110,12 @@ let router = createRouter({
})
router.beforeEach(async (to, from, next) => {
const { userResource, allUsers } = usersStore()
const { userResource } = usersStore()
let { isLoggedIn } = sessionStore()
try {
if (isLoggedIn) {
await userResource.promise
}
if (
isLoggedIn &&
(to.name == 'Lesson' ||
to.name == 'Batch' ||
to.name == 'Notifications' ||
to.name == 'Badge')
) {
await allUsers.promise
await userResource.reload()
}
} catch (error) {
isLoggedIn = false

View File

@@ -5,7 +5,7 @@ import router from '@/router'
import { ref, computed } from 'vue'
export const sessionStore = defineStore('lms-session', () => {
let { userResource, allUsers } = usersStore()
let { userResource } = usersStore()
function sessionUser() {
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
@@ -17,9 +17,6 @@ export const sessionStore = defineStore('lms-session', () => {
}
let user = ref(sessionUser())
if (user) {
allUsers.reload()
}
const isLoggedIn = computed(() => !!user.value)
const login = createResource({
@@ -44,20 +41,10 @@ export const sessionStore = defineStore('lms-session', () => {
},
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
cache: 'brand',
auto: true,
onSuccess(data) {
document.querySelector("link[rel='icon']").href = data.favicon
},
})
return {
user,
isLoggedIn,
login,
logout,
branding,
}
})

View File

@@ -9,16 +9,9 @@ export const usersStore = defineStore('lms-users', () => {
router.push('/login')
}
},
auto: true,
})
const allUsers = createResource({
url: 'lms.lms.api.get_all_users',
cache: ['allUsers'],
})
return {
userResource,
allUsers,
}
})

View File

@@ -1,218 +0,0 @@
import { Code } from "lucide-vue-next"
import { h, createApp } from "vue"
const DEFAULT_THEMES = ['light', 'dark'];
const COMMON_LANGUAGES = {
none: 'Auto-detect', apache: 'Apache', bash: 'Bash', cs: 'C#', cpp: 'C++', css: 'CSS', coffeescript: 'CoffeeScript', diff: 'Diff',
go: 'Go', html: 'HTML, XML', http: 'HTTP', json: 'JSON', java: 'Java', javascript: 'JavaScript', kotlin: 'Kotlin',
less: 'Less', lua: 'Lua', makefile: 'Makefile', markdown: 'Markdown', nginx: 'Nginx', objectivec: 'Objective-C',
php: 'PHP', perl: 'Perl', properties: 'Properties', python: 'Python', ruby: 'Ruby', rust: 'Rust', scss: 'SCSS',
sql: 'SQL', shell: 'Shell Session', swift: 'Swift', toml: 'TOML, also INI', typescript: 'TypeScript', yaml: 'YAML',
plaintext: 'Plaintext'
};
export class CodeBox {
api: any;
config: { themeName: any; themeURL: any; useDefaultTheme: any; };
readOnly: boolean;
data: { code: any; language: any; theme: any; };
highlightScriptID: string;
highlightCSSID: string;
codeArea: HTMLDivElement;
selectInput: HTMLInputElement;
selectDropIcon: HTMLElement;
constructor({ data, api, config, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.config = {
themeName: config.themeName && typeof config.themeName === 'string' ? config.themeName : '',
themeURL: config.themeURL && typeof config.themeURL === 'string' ? config.themeURL : '',
useDefaultTheme: (config.useDefaultTheme && typeof config.useDefaultTheme === 'string'
&& DEFAULT_THEMES.includes(config.useDefaultTheme.toLowerCase())) ? config.useDefaultTheme : 'dark',
};
this.data = {
code: data.code && typeof data.code === 'string' ? data.code : '',
language: data.language && typeof data.language === 'string' ? data.language : 'Auto-detect',
theme: data.theme && typeof data.theme === 'string' ? data.theme : this._getThemeURLFromConfig(),
};
this.highlightScriptID = 'highlightJSScriptElement';
this.highlightCSSID = 'highlightJSCSSElement';
this.codeArea = document.createElement('div');
this.selectInput = document.createElement('input');
this.selectDropIcon = document.createElement('i');
this._injectHighlightJSScriptElement();
this._injectHighlightJSCSSElement();
this.api.listeners.on(window, 'click', this._closeAllLanguageSelects, true);
}
static get isReadOnlySupported() {
return true
}
static get sanitize() {
return {
code: true,
language: false,
theme: false,
}
}
static get toolbox() {
const app = createApp({
render: () => h(Code, { size: 24, strokeWidth: 2, color: 'black' }),
});
const div = document.createElement('div');
app.mount(div);
return {
title: 'CodeBox',
icon: div.innerHTML
};
}
static get displayInToolbox() {
return true;
}
static get enableLineBreaks() {
return true;
}
render() {
const codeAreaHolder = document.createElement('pre');
const languageSelect = this._createLanguageSelectElement();
codeAreaHolder.setAttribute('class', 'codeBoxHolder');
this.codeArea.setAttribute('class', `codeBoxTextArea ${this.config.useDefaultTheme} ${this.data.language}`);
this.codeArea.setAttribute('contenteditable', 'true');
this.codeArea.innerHTML = this.data.code;
this.api.listeners.on(this.codeArea, 'blur', event => this._highlightCodeArea(event), false);
this.api.listeners.on(this.codeArea, 'paste', event => this._handleCodeAreaPaste(event), false);
codeAreaHolder.appendChild(this.codeArea);
!this.readOnly && codeAreaHolder.appendChild(languageSelect);
return codeAreaHolder;
}
save(blockContent) {
return Object.assign(this.data, { code: this.codeArea.innerHTML, theme: this._getThemeURLFromConfig() });
}
validate(savedData) {
if (!savedData.code.trim()) return false;
return true;
}
destroy() {
this.api.listeners.off(window, 'click', this._closeAllLanguageSelects, true);
this.api.listeners.off(this.codeArea, 'blur', event => this._highlightCodeArea(event), false);
this.api.listeners.off(this.codeArea, 'paste', event => this._handleCodeAreaPaste(event), false);
this.api.listeners.off(this.selectInput, 'click', event => this._handleSelectInputClick(event), false);
}
_createLanguageSelectElement() {
const selectHolder = document.createElement('div');
const selectPreview = document.createElement('div');
const languages = Object.entries(COMMON_LANGUAGES);
selectHolder.setAttribute('class', 'codeBoxSelectDiv');
this.selectDropIcon.setAttribute('class', `codeBoxSelectDropIcon ${this.config.useDefaultTheme}`);
this.selectDropIcon.innerHTML = '&#8595;';
this.selectInput.setAttribute('class', `codeBoxSelectInput ${this.config.useDefaultTheme}`);
this.selectInput.setAttribute('type', 'text');
this.selectInput.setAttribute('readonly', 'true');
this.selectInput.value = this.data.language;
this.api.listeners.on(this.selectInput, 'click', event => this._handleSelectInputClick(event), false);
selectPreview.setAttribute('class', 'codeBoxSelectPreview');
languages.forEach(language => {
const selectItem = document.createElement('p');
selectItem.setAttribute('class', `codeBoxSelectItem ${this.config.useDefaultTheme}`);
selectItem.setAttribute('data-key', language[0]);
selectItem.textContent = language[1];
this.api.listeners.on(selectItem, 'click', event => this._handleSelectItemClick(event, language), false);
selectPreview.appendChild(selectItem);
});
selectHolder.appendChild(this.selectDropIcon);
selectHolder.appendChild(this.selectInput);
selectHolder.appendChild(selectPreview);
return selectHolder;
}
_highlightCodeArea(event) {
window.hljs.highlightBlock(this.codeArea);
}
_handleCodeAreaPaste(event) {
event.stopPropagation();
}
_handleSelectInputClick(event) {
event.target.nextSibling.classList.toggle('codeBoxShow');
}
_handleSelectItemClick(event, language) {
event.target.parentNode.parentNode.querySelector('.codeBoxSelectInput').value = language[1];
event.target.parentNode.classList.remove('codeBoxShow');
this.codeArea.removeAttribute('class');
this.data.language = language[0];
this.codeArea.setAttribute('class', `codeBoxTextArea ${this.config.useDefaultTheme} ${this.data.language}`);
window.hljs.highlightBlock(this.codeArea);
}
_closeAllLanguageSelects() {
const selectPreviews = document.querySelectorAll('.codeBoxSelectPreview');
for (let i = 0, len = selectPreviews.length; i < len; i++) selectPreviews[i].classList.remove('codeBoxShow');
}
_injectHighlightJSScriptElement() {
const highlightJSScriptElement = document.querySelector(`#${this.highlightScriptID}`);
const highlightJSScriptURL = 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/highlight.min.js';
if (!highlightJSScriptElement) {
const script = document.createElement('script');
const head = document.querySelector('head');
script.setAttribute('src', highlightJSScriptURL);
script.setAttribute('id', this.highlightScriptID);
if (head) head.appendChild(script);
}
else highlightJSScriptElement.setAttribute('src', highlightJSScriptURL);
}
_injectHighlightJSCSSElement() {
const highlightJSCSSElement = document.querySelector(`#${this.highlightCSSID}`);
let highlightJSCSSURL = this._getThemeURLFromConfig();
if (!highlightJSCSSElement) {
const link = document.createElement('link');
const head = document.querySelector('head');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('href', highlightJSCSSURL);
link.setAttribute('id', this.highlightCSSID);
if (head) head.appendChild(link);
}
else highlightJSCSSElement.setAttribute('href', highlightJSCSSURL);
}
_getThemeURLFromConfig() {
let themeURL = `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/atom-one-${this.config.useDefaultTheme}.min.css`;
if (this.config.themeName) themeURL = `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/${this.config.themeName}.min.css`;
if (this.config.themeURL) themeURL = this.config.themeURL;
return themeURL;
}
}
export default CodeBox;

View File

@@ -1,32 +0,0 @@
import Embed from '@editorjs/embed'
import VideoBlock from '@/components/VideoBlock.vue'
import { createApp } from 'vue'
export class CustomEmbed extends Embed {
render() {
const container = super.render()
const { service, source, embed } = this.data
if (service === 'youtube' || service === 'vimeo') {
// Remove the iframe or existing embed content
container.innerHTML = ''
// Create a placeholder element for Vue component
const vueContainer = document.createElement('div')
vueContainer.setAttribute('data-service', service)
vueContainer.setAttribute('data-video-id', this.data.source)
// Append the Vue placeholder
container.appendChild(vueContainer)
console.log(source)
// Mount the Vue component (using a global Vue instance)
const app = createApp(VideoBlock, {
file: source,
type: 'video/youtube',
})
app.mount(vueContainer)
}
return container
}
}

View File

@@ -1,16 +1,13 @@
import { toast } from 'frappe-ui'
import { useTimeAgo } from '@vueuse/core'
import { BookOpen, Users, TrendingUp, Briefcase } from 'lucide-vue-next'
import { Quiz } from '@/utils/quiz'
import { Upload } from '@/utils/upload'
import Header from '@editorjs/header'
import Paragraph from '@editorjs/paragraph'
import { CodeBox } from '@/utils/code'
import NestedList from '@editorjs/nested-list'
import InlineCode from '@editorjs/inline-code'
import { watch } from 'vue'
import dayjs from '@/utils/dayjs'
import Embed from '@editorjs/embed'
import SimpleImage from '@editorjs/simple-image'
import NestedList from '@editorjs/nested-list'
import { watch } from 'vue'
export function createToast(options) {
toast({
@@ -40,12 +37,6 @@ export function formatTime(timeString) {
return formattedTime
}
export function formatNumber(number) {
return number.toLocaleString('en-IN', {
maximumFractionDigits: 0,
})
}
export function formatNumberIntoCurrency(number, currency) {
if (number) {
return number.toLocaleString('en-IN', {
@@ -80,31 +71,17 @@ export function getFileSize(file_size) {
return value
}
export function showToast(title, text, icon, iconClasses = null) {
if (!iconClasses) {
iconClasses =
icon == 'check'
? 'bg-green-600 text-white rounded-md p-px'
: 'bg-red-600 text-white rounded-md p-px'
}
export function showToast(title, text, icon) {
createToast({
title: title,
text: htmlToText(text),
icon: icon,
iconClasses: iconClasses,
iconClasses:
icon == 'check'
? 'bg-green-600 text-white rounded-md p-px'
: 'bg-red-600 text-white rounded-md p-px',
position: icon == 'check' ? 'bottom-right' : 'top-center',
timeout: 5,
})
}
export function getImgDimensions(imgSrc) {
return new Promise((resolve) => {
let img = new Image()
img.onload = function () {
let { width, height } = img
resolve({ width, height, ratio: width / height })
}
img.src = imgSrc
timeout: icon == 'check' ? 5 : 10,
})
}
@@ -137,22 +114,9 @@ export function getEditorTools() {
header: Header,
quiz: Quiz,
upload: Upload,
image: SimpleImage,
paragraph: {
class: Paragraph,
inlineToolbar: true,
config: {
preserveBlank: true,
},
},
codeBox: {
class: CodeBox,
config: {
themeURL:
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/dracula.min.css', // Optional
themeName: 'atom-one-dark', // Optional
useDefaultTheme: 'dark', // Optional. This also determines the background color of the language select drop-down
},
},
list: {
class: NestedList,
@@ -160,113 +124,19 @@ export function getEditorTools() {
defaultStyle: 'ordered',
},
},
inlineCode: {
class: InlineCode,
shortcut: 'CMD+SHIFT+M',
},
embed: {
class: Embed,
inlineToolbar: false,
config: {
services: {
youtube: {
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
embedUrl:
'https://www.youtube.com/embed/<%= remote_id %>',
html: '<iframe style="width:100%; height: 30rem;" frameborder="0" allowfullscreen></iframe>',
height: 320,
width: 580,
id: ([id, params]) => {
if (!params && id) {
return id
}
const paramsMap = {
start: 'start',
end: 'end',
t: 'start',
// eslint-disable-next-line camelcase
time_continue: 'start',
list: 'list',
}
let newParams = params
.slice(1)
.split('&')
.map((param) => {
const [name, value] = param.split('=')
if (!id && name === 'v') {
id = value
return null
}
if (!paramsMap[name]) {
return null
}
if (
value === 'LL' ||
value.startsWith('RDMM') ||
value.startsWith('FL')
) {
return null
}
return `${paramsMap[name]}=${value}`
})
.filter((param) => !!param)
return id + '?' + newParams.join('&')
},
},
youtube: true,
vimeo: true,
codepen: true,
aparat: {
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,
embedUrl:
'https://www.aparat.com/video/video/embed/videohash/<%= remote_id %>/vt/frame',
html: '<iframe style="margin: 0 auto; width: 100%; height: 25rem;" frameborder="0" scrolling="no" allowtransparency="true"></iframe>',
height: 300,
width: 600,
},
github: true,
slides: {
regex: /https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
embedUrl:
'https://docs.google.com/presentation/d/e/<%= remote_id %>/embed',
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
},
drive: {
regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/,
embedUrl:
'https://drive.google.com/file/d/<%= remote_id %>/preview',
html: "<iframe style='width: 100%; height: 25rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
},
docsPublic: {
regex: /https:\/\/docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
embedUrl:
'https://docs.google.com/document/d/<%= remote_id %>/preview',
html: "<iframe style='width: 100%; height: 40rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
},
sheetsPublic: {
regex: /https:\/\/docs\.google\.com\/spreadsheets\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
embedUrl:
'https://docs.google.com/spreadsheets/d/<%= remote_id %>/preview',
html: "<iframe style='width: 100%; height: 40rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
},
slidesPublic: {
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
embedUrl:
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
},
codesandbox: {
regex: /^https:\/\/codesandbox\.io\/(?:embed\/)?([A-Za-z0-9_-]+)(?:\?[^\/]*)?$/,
embedUrl:
'https://codesandbox.io/embed/<%= remote_id %>?view=editor+%2B+preview&module=%2Findex.html',
html: "<iframe style='width: 100%; height: 500px; border: 0; border-radius: 4px; overflow: hidden;' sandbox='allow-mods allow-forms allow-popups allow-scripts allow-same-origin' frameborder='0' allowfullscreen='true'></iframe>",
html: "<iframe width='100%' height='300' frameborder='0' allowfullscreen='true'></iframe>",
},
},
},
@@ -418,68 +288,26 @@ export function getSidebarLinks() {
return [
{
label: 'Courses',
icon: 'BookOpen',
icon: BookOpen,
to: 'Courses',
activeFor: [
'Courses',
'CourseDetail',
'Lesson',
'CreateCourse',
'CreateLesson',
],
activeFor: ['Courses', 'CourseDetail', 'Lesson'],
},
{
label: 'Batches',
icon: 'Users',
icon: Users,
to: 'Batches',
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchCreation'],
},
{
label: 'Certified Participants',
icon: 'GraduationCap',
to: 'CertifiedParticipants',
activeFor: ['CertifiedParticipants'],
activeFor: ['Batches', 'BatchDetail', 'Batch'],
},
{
label: 'Jobs',
icon: 'Briefcase',
icon: Briefcase,
to: 'Jobs',
activeFor: ['Jobs', 'JobDetail'],
},
{
label: 'Statistics',
icon: 'TrendingUp',
icon: TrendingUp,
to: 'Statistics',
activeFor: ['Statistics'],
},
]
}
export function getFormattedDateRange(
startDate,
endDate,
format = 'DD MMM YYYY'
) {
if (startDate === endDate) {
return dayjs(startDate).format(format)
}
return `${dayjs(startDate).format(format)} - ${dayjs(endDate).format(
format
)}`
}
export function getLineStartPosition(string, position) {
const charLength = 1
let char = ''
while (char !== '\n' && position > 0) {
position = position - charLength
char = string.substr(position, charLength)
}
if (char === '\n') {
position += 1
}
return position
}

View File

@@ -43,6 +43,7 @@ export class Quiz {
}
save(blockContent) {
console.log(blockContent)
return {
quiz: this.data.quiz,
}

View File

@@ -1,11 +0,0 @@
export function scrollTo(...options) {
if (!options || options.length === 0) return
const container = getScrollContainer()
if (!container) return
container.scrollTo(...options)
}
export function getScrollContainer() {
// window.scrollContainer is reference to the scroll container in DesktopLayout.vue and MobileLayout.vue
return window.scrollContainer
}

View File

@@ -1,7 +1,3 @@
import AudioBlock from '@/components/AudioBlock.vue'
import VideoBlock from '@/components/VideoBlock.vue'
import { createApp } from 'vue'
export class Upload {
constructor({ data, api, readOnly }) {
this.data = data
@@ -14,33 +10,27 @@ export class Upload {
render() {
this.wrapper = document.createElement('div')
this.renderUpload(this.data)
this.wrapper.innerHTML = this.renderUpload(this.data)
return this.wrapper
}
renderUpload(file) {
if (this.isVideo(file.file_type)) {
const app = createApp(VideoBlock, {
file: file.file_url,
})
app.mount(this.wrapper)
return
return `<video controls width='100%' controls controlsList='nodownload' class="mb-4">
<source src=${encodeURI(file.file_url)} type='video/mp4'>
</video>`
} else if (this.isAudio(file.file_type)) {
const app = createApp(AudioBlock, {
file: file.file_url,
})
app.mount(this.wrapper)
return
} else if (file.file_type == 'PDF') {
this.wrapper.innerHTML = `<iframe src="${encodeURI(
return `<audio controls width='100%' controls controlsList='nodownload' class="mb-4">
<source src=${encodeURI(file.file_url)} type='audio/mp3'>
</audio>`
} else if (file.file_type == 'pdf') {
return `<iframe src="${encodeURI(
file.file_url
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
return
} else {
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
return `<img class="mb-4" src=${encodeURI(
file.file_url
)} width='100%'>`
return
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
__version__ = "2.0.0"
__version__ = "1.0.0"

File diff suppressed because it is too large Load Diff

View File

@@ -97,13 +97,7 @@ override_doctype_class = {
# Hook on document methods and events
doc_events = {
"*": {
"on_change": [
"lms.lms.doctype.lms_badge.lms_badge.process_badges",
]
},
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
}
# Scheduled Tasks
@@ -111,8 +105,7 @@ doc_events = {
scheduler_events = {
"hourly": [
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals"
],
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
]
}
fixtures = ["Custom Field", "Function", "Industry"]
@@ -153,8 +146,9 @@ website_redirects = [
{"source": "/update-profile", "target": "/edit-profile"},
{"source": "/courses", "target": "/lms/courses"},
{
"source": r"^/courses/.*$",
"source": r"/courses/([^/]*)",
"target": "/lms/courses",
"match_with_query_string": True,
},
{"source": "/batches", "target": "/lms/batches"},
{
@@ -176,7 +170,52 @@ update_website_context = [
]
jinja = {
"methods": ["lms.lms.utils.get_signup_optin_checks"],
"methods": [
"lms.page_renderers.get_profile_url",
"lms.overrides.user.get_enrolled_courses",
"lms.overrides.user.get_course_membership",
"lms.overrides.user.get_authored_courses",
"lms.overrides.user.get_palette",
"lms.lms.utils.get_membership",
"lms.lms.utils.get_lessons",
"lms.lms.utils.get_tags",
"lms.lms.utils.get_instructors",
"lms.lms.utils.get_students",
"lms.lms.utils.get_average_rating",
"lms.lms.utils.is_certified",
"lms.lms.utils.get_lesson_index",
"lms.lms.utils.get_lesson_url",
"lms.lms.utils.get_chapters",
"lms.lms.utils.get_slugified_chapter_title",
"lms.lms.utils.get_progress",
"lms.lms.utils.render_html",
"lms.lms.utils.is_mentor",
"lms.lms.utils.is_cohort_staff",
"lms.lms.utils.get_mentors",
"lms.lms.utils.get_reviews",
"lms.lms.utils.is_eligible_to_review",
"lms.lms.utils.get_initial_members",
"lms.lms.utils.get_sorted_reviews",
"lms.lms.utils.is_instructor",
"lms.lms.utils.convert_number_to_character",
"lms.lms.utils.get_signup_optin_checks",
"lms.lms.utils.get_popular_courses",
"lms.lms.utils.format_amount",
"lms.lms.utils.first_lesson_exists",
"lms.lms.utils.get_courses_under_review",
"lms.lms.utils.has_course_instructor_role",
"lms.lms.utils.has_course_moderator_role",
"lms.lms.utils.get_certificates",
"lms.lms.utils.format_number",
"lms.lms.utils.get_lesson_count",
"lms.lms.utils.get_all_memberships",
"lms.lms.utils.get_filtered_membership",
"lms.lms.utils.show_start_learing_cta",
"lms.lms.utils.can_create_courses",
"lms.lms.utils.get_telemetry_boot_info",
"lms.lms.utils.is_onboarding_complete",
"lms.www.utils.is_student",
],
"filters": [],
}
## Specify the additional tabs to be included in the user profile page.
@@ -193,10 +232,28 @@ jinja = {
# ]
has_website_permission = {
"LMS Certificate Evaluation": "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.has_website_permission",
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.has_website_permission",
"LMS Certificate Evaluation": "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.has_website_permission"
}
profile_mandatory_fields = [
"first_name",
"last_name",
"user_image",
"bio",
"linkedin",
"education",
"skill",
"preferred_functions",
"preferred_industries",
"dream_companies",
"attire",
"collaboration",
"role",
"location_preference",
"time",
"company_type",
]
## Markdown Macros for Lessons
lms_markdown_macro_renderers = {
"Exercise": "lms.plugins.exercise_renderer",
@@ -213,7 +270,6 @@ lms_markdown_macro_renderers = {
page_renderer = [
"lms.page_renderers.ProfileRedirectPage",
"lms.page_renderers.ProfilePage",
"lms.page_renderers.CoursePage",
]
# set this to "/" to have profiles on the top-level

View File

@@ -90,11 +90,11 @@ def create_moderator_role():
def create_evaluator_role():
if not frappe.db.exists("Role", "Batch Evaluator"):
if not frappe.db.exists("Role", "Class Evaluator"):
role = frappe.new_doc("Role")
role.update(
{
"role_name": "Batch Evaluator",
"role_name": "Class Evaluator",
"home_page": "",
"desk_access": 0,
}

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import get_link_to_form, add_months, getdate
from frappe.utils import get_link_to_form
from frappe.utils.user import get_system_managers
from lms.lms.utils import validate_image
@@ -19,17 +19,6 @@ class JobOpportunity(Document):
frappe.utils.validate_url(self.company_website, True)
def update_job_openings():
old_jobs = frappe.get_all(
"Job Opportunity",
filters={"status": "Open", "creation": ["<=", add_months(getdate(), -3)]},
pluck="name",
)
for job in old_jobs:
frappe.db.set_value("Job Opportunity", job, "status", "Closed")
@frappe.whitelist()
def report(job, reason):
system_managers = get_system_managers(only_name=True)

View File

@@ -30,23 +30,13 @@ class LMSJobApplication(Document):
"full_name": frappe.db.get_value("User", self.user, "full_name"),
"job_title": self.job_title,
}
resume = frappe.get_doc(
"File",
{
"file_name": self.resume,
},
)
frappe.sendmail(
recipients=company_email,
subject=subject,
template="job_application",
args=args,
attachments=[
{
"fname": resume.file_name,
"fcontent": resume.get_content(),
}
],
attachments=[self.resume],
header=[subject, "green"],
retry=3,
)

View File

@@ -4,8 +4,6 @@
import frappe
from frappe.translate import get_all_translations
from frappe import _
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
@frappe.whitelist()
@@ -150,7 +148,7 @@ def get_user_info():
user = frappe.db.get_value(
"User",
frappe.session.user,
["name", "email", "enabled", "user_image", "full_name", "user_type", "username"],
["name", "email", "enabled", "user_image", "full_name", "user_type"],
as_dict=1,
)
user["roles"] = frappe.get_roles(user.name)
@@ -290,272 +288,3 @@ def get_branding():
"brand_html": frappe.db.get_single_value("Website Settings", "brand_html"),
"favicon": frappe.db.get_single_value("Website Settings", "favicon"),
}
@frappe.whitelist()
def get_unsplash_photos(keyword=None):
from lms.unsplash import get_list, get_by_keyword
if keyword:
return get_by_keyword(keyword)
return frappe.cache().get_value("unsplash_photos", generator=get_list)
@frappe.whitelist()
def get_evaluator_details(evaluator):
frappe.only_for("Batch Evaluator")
if not frappe.db.exists("Google Calendar", {"user": evaluator}):
calendar = frappe.new_doc("Google Calendar")
calendar.update({"user": evaluator, "calendar_name": evaluator})
calendar.insert()
else:
calendar = frappe.db.get_value(
"Google Calendar", {"user": evaluator}, ["name", "authorization_code"], as_dict=1
)
if frappe.db.exists("Course Evaluator", {"evaluator": evaluator}):
doc = frappe.get_doc("Course Evaluator", evaluator, as_dict=1)
else:
doc = frappe.new_doc("Course Evaluator")
doc.evaluator = evaluator
doc.insert()
return {
"slots": doc.as_dict(),
"calendar": calendar.name,
"is_authorised": calendar.authorization_code,
}
@frappe.whitelist(allow_guest=True)
def get_certified_participants():
LMSCertificate = DocType("LMS Certificate")
participants = (
frappe.qb.from_(LMSCertificate)
.select(LMSCertificate.member)
.distinct()
.where(LMSCertificate.published == 1)
.orderby(LMSCertificate.creation, order=frappe.qb.desc)
.run(as_dict=1)
)
participant_details = []
for participant in participants:
details = frappe.db.get_value(
"User",
participant.member,
["name", "full_name", "username", "user_image"],
as_dict=True,
)
course_names = frappe.get_all(
"LMS Certificate", {"member": participant.member}, pluck="course"
)
courses = []
for course in course_names:
courses.append(frappe.db.get_value("LMS Course", course, "title"))
details["courses"] = courses
participant_details.append(details)
return participant_details
@frappe.whitelist()
def get_assigned_badges(member):
assigned_badges = frappe.get_all(
"LMS Badge Assignment",
{"member": member},
["badge"],
as_dict=1,
)
for badge in assigned_badges:
badge.update(
frappe.db.get_value("LMS Badge", badge.badge, ["name", "title", "image"])
)
return assigned_badges
@frappe.whitelist()
def get_certificates(member):
"""Get certificates for a member."""
return frappe.get_all(
"LMS Certificate",
filters={"member": member},
fields=["name", "course", "course_title", "issue_date", "template"],
order_by="creation desc",
)
@frappe.whitelist()
def get_all_users():
users = frappe.get_all(
"User",
{
"enabled": 1,
},
["name", "full_name", "user_image"],
)
return {user.name: user for user in users}
@frappe.whitelist()
def mark_as_read(name):
doc = frappe.get_doc("Notification Log", name)
doc.read = 1
doc.save(ignore_permissions=True)
@frappe.whitelist()
def mark_all_as_read():
notifications = frappe.get_all(
"Notification Log", {"for_user": frappe.session.user, "read": 0}, pluck="name"
)
for notification in notifications:
mark_as_read(notification)
@frappe.whitelist(allow_guest=True)
def get_sidebar_settings():
lms_settings = frappe.get_single("LMS Settings")
sidebar_items = frappe._dict()
items = [
"courses",
"batches",
"certified_participants",
"jobs",
"statistics",
"notifications",
]
for item in items:
sidebar_items[item] = lms_settings.get(item)
if len(lms_settings.sidebar_items):
web_pages = frappe.get_all(
"LMS Sidebar Item",
{"parenttype": "LMS Settings", "parentfield": "sidebar_items"},
["web_page", "route", "title as label", "icon"],
)
for page in web_pages:
page.to = page.route
sidebar_items.web_pages = web_pages
return sidebar_items
@frappe.whitelist()
def update_sidebar_item(webpage, icon):
filters = {
"web_page": webpage,
"parenttype": "LMS Settings",
"parentfield": "sidebar_items",
"parent": "LMS Settings",
}
if frappe.db.exists("LMS Sidebar Item", filters):
frappe.db.set_value("LMS Sidebar Item", filters, "icon", icon)
else:
doc = frappe.new_doc("LMS Sidebar Item")
doc.update(filters)
doc.icon = icon
doc.insert()
@frappe.whitelist()
def delete_sidebar_item(webpage):
return frappe.db.delete(
"LMS Sidebar Item",
{
"web_page": webpage,
"parenttype": "LMS Settings",
"parentfield": "sidebar_items",
"parent": "LMS Settings",
},
)
@frappe.whitelist()
def delete_lesson(lesson, chapter):
frappe.db.delete("Lesson Reference", {"parent": chapter, "lesson": lesson})
frappe.db.delete("Course Lesson", lesson)
@frappe.whitelist()
def update_lesson_index(lesson, sourceChapter, targetChapter, idx):
hasMoved = sourceChapter == targetChapter
update_source_chapter(lesson, sourceChapter, idx, hasMoved)
if not hasMoved:
update_target_chapter(lesson, targetChapter, idx)
def update_source_chapter(lesson, chapter, idx, hasMoved=False):
lessons = frappe.get_all(
"Lesson Reference",
{
"parent": chapter,
},
pluck="lesson",
order_by="idx",
)
lessons.remove(lesson)
if not hasMoved:
frappe.db.delete("Lesson Reference", {"parent": chapter, "lesson": lesson})
else:
lessons.insert(idx, lesson)
update_index(lessons, chapter)
def update_target_chapter(lesson, chapter, idx):
lessons = frappe.get_all(
"Lesson Reference",
{
"parent": chapter,
},
pluck="lesson",
order_by="idx",
)
lessons.insert(idx, lesson)
new_lesson_reference = frappe.new_doc("Lesson Reference")
new_lesson_reference.update(
{
"lesson": lesson,
"parent": chapter,
"parenttype": "Course Chapter",
"parentfield": "lessons",
}
)
new_lesson_reference.insert()
update_index(lessons, chapter)
def update_index(lessons, chapter):
for row in lessons:
frappe.db.set_value(
"Lesson Reference", {"lesson": row, "parent": chapter}, "idx", lessons.index(row) + 1
)
@frappe.whitelist(allow_guest=True)
def get_categories(doctype, filters):
categoryOptions = []
categories = frappe.get_all(
doctype,
filters,
pluck="category",
)
categories = list(set(categories))
for category in categories:
if category:
categoryOptions.append({"label": category, "value": category})
return categoryOptions

View File

@@ -8,11 +8,7 @@
"engine": "InnoDB",
"field_order": [
"evaluator",
"schedule",
"unavailability_section",
"unavailable_from",
"column_break_ahzi",
"unavailable_to"
"schedule"
],
"fields": [
{
@@ -27,30 +23,11 @@
"fieldtype": "Table",
"label": "Schedule",
"options": "Evaluator Schedule"
},
{
"fieldname": "unavailability_section",
"fieldtype": "Section Break",
"label": "Unavailability"
},
{
"fieldname": "column_break_ahzi",
"fieldtype": "Column Break"
},
{
"fieldname": "unavailable_from",
"fieldtype": "Date",
"label": "From"
},
{
"fieldname": "unavailable_to",
"fieldtype": "Date",
"label": "To"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-04-15 18:45:08.614466",
"modified": "2023-07-13 11:30:22.641076",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Evaluator",
@@ -89,7 +66,7 @@
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"role": "Class Evaluator",
"share": 1,
"write": 1
}

View File

@@ -6,25 +6,15 @@ from frappe import _
from frappe.model.document import Document
from lms.lms.utils import get_evaluator
from datetime import datetime
from frappe.utils import get_time, getdate
class CourseEvaluator(Document):
def validate(self):
self.validate_time_slots()
self.validate_unavailability()
def validate_unavailability(self):
if (
self.unavailable_from
and self.unavailable_to
and getdate(self.unavailable_from) >= getdate(self.unavailable_to)
):
frappe.throw(_("Unavailable From Date cannot be greater than Unavailable To Date"))
def validate_time_slots(self):
for schedule in self.schedule:
if get_time(schedule.start_time) >= get_time(schedule.end_time):
if schedule.start_time >= schedule.end_time:
frappe.throw(_("Start Time cannot be greater than End Time"))
self.validate_overlaps(schedule)
@@ -36,21 +26,11 @@ class CourseEvaluator(Document):
overlap = False
for slot in same_day_slots:
if (
get_time(schedule.start_time)
<= get_time(slot.start_time)
< get_time(schedule.end_time)
):
if schedule.start_time <= slot.start_time < schedule.end_time:
overlap = True
if (
get_time(schedule.start_time)
< get_time(slot.end_time)
<= get_time(schedule.end_time)
):
if schedule.start_time < slot.end_time <= schedule.end_time:
overlap = True
if get_time(slot.start_time) < get_time(schedule.start_time) and get_time(
schedule.end_time
) < get_time(slot.end_time):
if slot.start_time < schedule.start_time and schedule.end_time < slot.end_time:
overlap = True
if overlap:

View File

@@ -7,7 +7,6 @@ from frappe.model.document import Document
from frappe.utils.telemetry import capture
from lms.lms.utils import get_course_progress
from ...md import find_macros
import json
class CourseLesson(Document):
@@ -90,12 +89,10 @@ class CourseLesson(Document):
@frappe.whitelist()
def save_progress(lesson, course):
membership = frappe.db.exists(
"LMS Enrollment", {"course": course, "member": frappe.session.user}
"LMS Enrollment", {"member": frappe.session.user, "course": course}
)
if not membership:
return
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
return 0
quiz_completed = get_quiz_progress(lesson)
if not quiz_completed:
@@ -104,7 +101,7 @@ def save_progress(lesson, course):
if frappe.db.exists(
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
):
return
return 0
frappe.get_doc(
{
@@ -116,39 +113,21 @@ def save_progress(lesson, course):
).save(ignore_permissions=True)
progress = get_course_progress(course)
# Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necesary for badge to get assigned.
enrollment = frappe.get_doc("LMS Enrollment", membership)
enrollment.progress = progress
enrollment.save()
enrollment.run_method("on_change")
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
return progress
def get_quiz_progress(lesson):
lesson_details = frappe.db.get_value(
"Course Lesson", lesson, ["body", "content"], as_dict=1
)
quizzes = []
if lesson_details.content:
content = json.loads(lesson_details.content)
for block in content.get("blocks"):
if block.get("type") == "quiz":
quizzes.append(block.get("data").get("quiz"))
elif lesson_details.body:
macros = find_macros(lesson_details.body)
quizzes = [value for name, value in macros if name == "Quiz"]
body = frappe.db.get_value("Course Lesson", lesson, "body")
macros = find_macros(body)
quizzes = [value for name, value in macros if name == "Quiz"]
for quiz in quizzes:
passing_percentage = frappe.db.get_value("LMS Quiz", quiz, "passing_percentage")
if not frappe.db.exists(
"LMS Quiz Submission",
{
"quiz": quiz,
"member": frappe.session.user,
"owner": frappe.session.user,
"percentage": [">=", passing_percentage],
},
):

View File

@@ -86,6 +86,7 @@
"label": "Comments"
},
{
"fetch_from": "course.evaluator",
"fieldname": "evaluator",
"fieldtype": "Link",
"label": "Evaluator",

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