Skip to content

Learnpath: Implement tool home page (LP list), new interface - refs #4763 #6589

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,10 @@ img.course-tool__icon {
}
}

.ghosting { opacity: .6; }
.chosen { outline: 2px solid #ddd; }
.dragging { outline: 2px solid var(--support-5, #f60); }

@import "~@fancyapps/fancybox/dist/jquery.fancybox.css";
@import "~timepicker/jquery.timepicker.min.css";
@import "~qtip2/dist/jquery.qtip.min.css";
32 changes: 19 additions & 13 deletions assets/vue/components/StudentViewButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,30 @@ const securityStore = useSecurityStore()
const { isCoach } = useUserSessionSubscription()

const isStudentView = computed({
async set() {
const studentView = await permissionService.toogleStudentView()
async set(v) {
try {
const resp = await permissionService.toogleStudentView()
const mode = (typeof resp === "string" ? resp : resp?.data || "").toString().toLowerCase()
const desired = mode.includes("student")

platformConfigStore.studentView = studentView

emit("change", studentView)
platformConfigStore.studentView = desired
emit("change", desired)
} catch (e) {
console.warn("[SVB] toggle failed", e)
const desired = !platformConfigStore.isStudentViewActive
platformConfigStore.studentView = desired
emit("change", desired)
}
},
get() {
return platformConfigStore.isStudentViewActive
},
})

const showButton = computed(() => {
return (
securityStore.isAuthenticated &&
cidReqStore.course &&
(securityStore.isCourseAdmin || securityStore.isAdmin || isCoach.value) &&
"true" === platformConfigStore.getSetting("course.student_view_enabled")
)
})
const showButton = computed(() =>
securityStore.isAuthenticated &&
cidReqStore.course &&
(securityStore.isCourseAdmin || securityStore.isAdmin || isCoach.value) &&
platformConfigStore.getSetting("course.student_view_enabled") === "true"
)
</script>
138 changes: 138 additions & 0 deletions assets/vue/components/lp/LpCardItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<script setup>
import { computed } from "vue"
import { useI18n } from "vue-i18n"

const { t } = useI18n()

const props = defineProps({
lp: { type: Object, required: true },
canEdit: { type: Boolean, default: false },
ringDash: { type: Function, required: true },
ringValue: { type: Function, required: true },
})
const emit = defineEmits([
"open","edit","report","settings","build",
"toggle-visible","toggle-publish","delete"
])

const dateText = computed(() => {
const v = props.lp?.dateText ?? ""
return typeof v === "string" ? v.trim() : ""
})
</script>

<template>
<div class="relative rounded-2xl border border-gray-25 bg-white px-4 pt-3 pb-4 min-h-[220px] flex flex-col">
<button
v-if="canEdit"
class="drag-handle2 absolute left-3 top-3 w-8 h-8 grid place-content-center rounded-lg text-gray-50 hover:text-gray-90 hover:bg-gray-15 cursor-move"
:title="t('Drag to reorder')" :aria-label="t('Drag to reorder')"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
<circle cx="4" cy="3" r="1.2" /><circle cx="4" cy="7" r="1.2" /><circle cx="4" cy="11" r="1.2" />
<circle cx="10" cy="3" r="1.2" /><circle cx="10" cy="7" r="1.2" /><circle cx="10" cy="11" r="1.2" />
</svg>
</button>

<details v-if="canEdit" class="absolute right-3 top-3">
<summary
class="w-8 h-8 grid place-content-center rounded-lg border border-gray-25 hover:bg-gray-15 cursor-pointer"
:title="t('More')" :aria-label="t('More')"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/>
</svg>
</summary>
<div class="absolute right-0 mt-2 w-44 bg-white border border-gray-25 rounded-xl shadow p-1 z-10">
<button class="w-full text-left px-3 py-2 rounded hover:bg-gray-15" @click="emit('settings', lp)">{{ t('Settings') }}</button>
<button class="w-full text-left px-3 py-2 rounded hover:bg-gray-15" @click="emit('toggle-visible', lp)">{{ t('Toggle visibility') }}</button>
<button class="w-full text-left px-3 py-2 rounded hover:bg-gray-15" @click="emit('toggle-publish', lp)">{{ t('Publish / Unpublish') }}</button>
<button class="w-full text-left px-3 py-2 rounded hover:bg-gray-15 text-danger" @click="emit('delete', lp)">{{ t('Delete') }}</button>
</div>
</details>

<div class="mt-2 grid grid-cols-[80px_1fr] gap-3 items-start pr-10 pl-8">
<div class="w-20 h-20 rounded-xl overflow-hidden ring-1 ring-gray-25 bg-gray-15 shrink-0">
<img v-if="lp.coverUrl" :src="lp.coverUrl" alt="" class="w-full h-full object-cover" />
<div v-else class="w-full h-full grid place-content-center text-gray-40">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="opacity-70">
<rect x="3" y="3" width="18" height="18" rx="3" stroke-width="1.5"/>
<path d="M3 16l4-4 3 3 5-5 6 6" stroke-width="1.5"/>
<circle cx="9" cy="8" r="1.3" stroke-width="1.2"/>
</svg>
</div>
</div>

<div class="min-w-0">
<h3 class="font-semibold text-gray-90 leading-snug">
<button
class="underline-offset-2 hover:underline focus:underline text-left"
@click="emit('open', lp)"
:title="t('Open')"
>
{{ lp.title || t('LP title here') }}
</button>
</h3>

<div v-if="lp.prerequisiteName" class="mt-1 text-caption text-support-5 flex items-center gap-1.5">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<circle cx="12" cy="12" r="3"/>
</svg>
<span class="font-medium">{{ t('Pre-requisite:') }}</span>
<span class="text-support-5">{{ lp.prerequisiteName }}</span>
</div>
</div>

<p class="col-span-2 mt-3 text-caption text-gray-50">
{{ dateText }}
</p>
</div>

<div class="mt-auto pt-3 flex items-center pl-8">
<div class="flex items-center gap-2">
<div class="relative w-10 h-10">
<svg viewBox="0 0 40 40" class="w-10 h-10">
<circle cx="20" cy="20" r="16" stroke-width="3.5" class="text-gray-25" fill="none" stroke="currentColor" />
<circle
cx="20" cy="20" r="16" stroke-width="3.5" fill="none"
:stroke-dasharray="ringDash(lp.progress)"
stroke-linecap="round"
class="text-support-5"
stroke="currentColor"
transform="rotate(-90 20 20)"
/>
</svg>
<span class="absolute -top-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-support-5 ring-2 ring-white" aria-hidden/>
<div class="absolute inset-0 grid place-content-center text-tiny font-semibold text-gray-90">
{{ ringValue(lp.progress) }}%
</div>
</div>
<span class="text-caption text-gray-50">
{{ ringValue(lp.progress) === 100 ? t('Completed') : t('Progress') }}
</span>
</div>

<div v-if="canEdit" class="ml-auto flex items-center gap-4">
<button class="opacity-80 hover:opacity-100" :title="t('Reports')" :aria-label="t('Reports')" @click="emit('report', lp)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M4 19h16M6 17V7m6 10V5m6 12v-8" stroke-width="1.7" stroke-linecap="round"/>
</svg>
</button>

<button class="opacity-80 hover:opacity-100" :title="t('Build')" :aria-label="t('Build')" @click="emit('build', lp)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="9" y="9" width="10" height="10" rx="2" stroke-width="1.7"/>
<rect x="5" y="5" width="10" height="10" rx="2" stroke-width="1.7"/>
</svg>
</button>

<button class="opacity-80 hover:opacity-100" :title="t('Visibility')" :aria-label="t('Visibility')" @click="emit('toggle-visible', lp)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12Z" stroke-width="1.7"/>
<circle cx="12" cy="12" r="3" stroke-width="1.7"/>
</svg>
</button>
</div>
</div>
</div>
</template>
183 changes: 183 additions & 0 deletions assets/vue/components/lp/LpCategorySection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<script setup>
import { computed, ref, watch, onMounted } from "vue"
import { useRoute } from "vue-router"
import Draggable from "vuedraggable"
import LpCardItem from "./LpCardItem.vue"
import lpService from "../../services/lpService"
import { useI18n } from "vue-i18n"

const { t } = useI18n()

const props = defineProps({
title: { type: String, default: "Learning Path Category" },
category: { type: Object, required: true },
list: { type: Array, default: () => [] },
canEdit: { type: Boolean, default: false },
ringDash: { type: Function, required: true },
ringValue: { type: Function, required: true },
})
const emit = defineEmits([
"open","edit","report","settings","build",
"toggle-visible","toggle-publish","delete",
"reorder"
])

const displayTitle = computed(() => props.title || t("Learning Path Category"))

const localList = ref([...(props.list ?? [])])
const dragging = ref(false)

watch(() => props.list, (nv) => {
if (dragging.value) return
localList.value = [...(nv ?? [])]
}, { immediate: true })

function onEndCat() {
dragging.value = false
emit("reorder", localList.value.map(i => i.iid))
}

const route = useRoute()
const cid = computed(() => Number(route.query?.cid ?? 0) || undefined)
const sid = computed(() => Number(route.query?.sid ?? 0) || undefined)
const node = computed(() => Number(route.params?.node ?? 0) || undefined)

const goCat = (action, extraParams = {}) => {
const url = lpService.buildLegacyActionUrl(action, {
cid: cid.value, sid: sid.value, node: node.value,
params: { id: props.category.iid, ...extraParams },
})
window.location.assign(url)
}
const onCatEdit = () => goCat("add_lp_category")
const onCatAddUsers = () => goCat("add_users_to_category")
const onCatToggleVisibility = () => {
const vis = props.category.visibility ?? props.category.visible
const next = typeof vis === "number" ? (vis ? 0 : 1) : 1
goCat("toggle_category_visibility", { new_status: next })
}
const onCatTogglePublish = () => {
const pub = props.category.isPublished ?? props.category.published
let next = 1
if (typeof pub === "number") next = pub ? 0 : 1
if (typeof pub === "string") next = pub === "v" ? 0 : 1
goCat("toggle_category_publish", { new_status: next })
}
const onCatDelete = () => {
const label = (props.category.title || "").trim() || t("Category")
const msg = `${t("Are you sure to delete:")} ${label}?`
if (confirm(msg)) {
goCat("delete_lp_category")
}
}

const isOpen = ref(true)
const storageKey = computed(() => `lpCatOpen:${props.category?.iid || props.title}`)
onMounted(() => {
const saved = localStorage.getItem(storageKey.value)
if (saved !== null) isOpen.value = saved === "1"
})
watch(isOpen, v => localStorage.setItem(storageKey.value, v ? "1" : "0"))
const panelId = computed(() => `cat-panel-${props.category?.iid || props.title}`)
const toggleOpen = () => { if (localList.value.length) isOpen.value = !isOpen.value }
function onChangeCat() {
emit("reorder", localList.value.map(i => i.iid))
}
</script>

<template>
<section class="relative ml-2 rounded-2xl shadow-[0_1px_8px_rgba(0,0,0,.04)]">
<header class="relative bg-support-6 rounded-t-2xl flex items-center justify-between pl-0 pr-4 py-3">
<span class="pointer-events-none absolute inset-y-0 -left-1.5 w-1.5 bg-support-5 rounded-l-2xl" aria-hidden />
<div class="flex items-center gap-3">
<button
v-if="canEdit"
class="w-8 h-8 grid place-content-center rounded-lg text-gray-50 hover:bg-gray-15 hover:text-gray-90"
:title="t('Drag category')" :aria-label="t('Drag category')"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
<circle cx="4" cy="3" r="1.2" /><circle cx="4" cy="7" r="1.2" /><circle cx="4" cy="11" r="1.2" />
<circle cx="10" cy="3" r="1.2" /><circle cx="10" cy="7" r="1.2" /><circle cx="10" cy="11" r="1.2" />
</svg>
</button>
<h2 class="text-body-1 font-semibold text-gray-90">{{ displayTitle }}</h2>
</div>

<div class="flex items-center gap-2">
<div class="text-tiny text-gray-50">{{ localList.length }} {{ t('Lessons') }}</div>
<details v-if="canEdit" class="relative z-30">
<summary
class="list-none w-8 h-8 grid place-content-center rounded-lg border border-gray-25 hover:bg-gray-15 cursor-pointer"
:title="t('Category options')" :aria-label="t('Category options')"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/>
</svg>
</summary>
<div class="absolute right-0 top-full mt-2 w-60 bg-white border border-gray-25 rounded-xl shadow-xl p-1 z-50" @mousedown.stop @click.stop>
<button class="w-full text-left px-3 py-2 rounded hover:bg-gray-15" @click="onCatEdit">{{ t('Edit category') }}</button>
<button class="w-full text-left px-3 py-2 rounded hover:bg-gray-15" @click="onCatAddUsers">{{ t('Add users to category') }}</button>
<div class="my-1 h-px bg-gray-15"></div>
<button class="w-full text-left px-3 py-2 rounded hover:bg-gray-15" @click="onCatToggleVisibility">{{ t('Toggle visibility') }}</button>
<button class="w-full text-left px-3 py-2 rounded hover:bg-gray-15" @click="onCatTogglePublish">{{ t('Publish / Unpublish') }}</button>
<div class="my-1 h-px bg-gray-15"></div>
<button class="w-full text-left px-3 py-2 rounded hover:bg-gray-15 text-danger" @click="onCatDelete">{{ t('Delete') }}</button>
</div>
</details>

<button
v-if="localList.length"
:aria-expanded="isOpen ? 'true' : 'false'"
:aria-controls="panelId"
class="w-8 h-8 grid place-content-center rounded-lg border border-gray-25 hover:bg-gray-15 transition"
:title="t('Expand / Collapse')"
@click="toggleOpen"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
class="transition-transform duration-200"
:class="isOpen ? 'rotate-180' : ''">
<path d="M6 9l6 6 6-6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</header>

<div v-if="isOpen && localList.length" :id="panelId" class="px-4 pb-4 bg-white rounded-b-2xl">
<Draggable
v-model="localList"
item-key="iid"
:disabled="!canEdit"
handle=".drag-handle2"
:animation="180"
tag="div"
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3 mt-5"
ghost-class="ghosting"
chosen-class="chosen"
drag-class="dragging"
:prevent-on-filter="true"
:force-fallback="true"
:fallback-on-body="true"
:empty-insert-threshold="10"
@start="dragging = true"
@end="onEndCat"
>
<template #item="{ element }">
<LpCardItem
:lp="element"
:canEdit="canEdit"
:ringDash="ringDash"
:ringValue="ringValue"
@open="$emit('open', element)"
@edit="$emit('edit', element)"
@report="$emit('report', element)"
@settings="$emit('settings', element)"
@build="$emit('build', element)"
@toggle-visible="$emit('toggle-visible', element)"
@toggle-publish="$emit('toggle-publish', element)"
@delete="$emit('delete', element)"
/>
</template>
</Draggable>
</div>
</section>
</template>
5 changes: 5 additions & 0 deletions assets/vue/components/lp/LpLayout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div class="px-4 md:px-6 py-4">
<RouterView />
</div>
</template>
Loading
Loading