Skip to content
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
81 changes: 57 additions & 24 deletions assets/vue/components/course/AdminCourseCard.vue
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
<template>
<Card class="course-card relative overflow-hidden">
<Card class="course-card rounded-2xl overflow-hidden bg-white shadow-sm">
<template #header>
<img
:src="course.illustrationUrl || PLACEHOLDER"
:alt="course.title"
class="w-full h-40 object-cover"
/>

<button
@click.stop="toggleFavorite"
:aria-label="isFavorite ? t('Unmark favorite') : t('Mark favorite')"
class="absolute top-2 right-2 text-yellow-400 hover:text-yellow-500"
>
<i :class="isFavorite ? 'pi pi-star-fill' : 'pi pi-star'" />
</button>
<div class="relative aspect-[16/9] w-full overflow-hidden bg-gray-100">
<img
:src="imageUrl"
:alt="course.title || 'Course illustration'"
class="absolute inset-0 h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@error="onImgError"
/>
<button
@click.stop="toggleFavorite"
:aria-label="isFavorite ? t('Unmark favorite') : t('Mark favorite')"
class="absolute top-2 right-2 grid place-content-center w-10 h-10 rounded-full bg-white/80 backdrop-blur text-yellow-400 hover:text-yellow-500 shadow"
>
<i :class="isFavorite ? 'pi pi-star-fill' : 'pi pi-star'" />
</button>
</div>
</template>

<template #title>
<div class="flex flex-col gap-1">
<span
class="font-semibold truncate"
:title="course.title"
>
<div class="course-card__title flex items-start gap-2">
<span class="font-semibold leading-snug line-clamp-2" :title="course.title">
{{ course.title }}
</span>
</div>
Expand All @@ -42,7 +43,7 @@

<script setup>
import Card from "primevue/card"
import { ref, watch } from "vue"
import { ref, watch, computed } from "vue"
import { useI18n } from "vue-i18n"
import courseService from "../../services/courseService"
import { useSecurityStore } from "../../store/securityStore"
Expand All @@ -58,6 +59,40 @@ const emit = defineEmits(["favorite-toggled"])
const isFavorite = ref(false)
const PLACEHOLDER = "/img/session_default.svg"

function normalizeUrl(u) {
if (!u || typeof u !== "string") return null
const s = u.trim()
if (s.startsWith("http://") || s.startsWith("https://") || s.startsWith("/")) return s
return `/${s.replace(/^\/+/, "")}`
}

const imageUrl = computed(() => {
const c = props.course || {}
const candidates = [
c.illustrationUrl,
c?.illustration?.url,
c?.illustration?.contentUrl,
c?.image?.url,
c.pictureUrl,
c.picture,
c.thumbnailUrl,
c.thumbnail,
c.coverUrl,
c?.cover?.url,
]
for (const cand of candidates) {
const n = normalizeUrl(cand)
if (n) return n
}
return PLACEHOLDER
})

function onImgError(e) {
if (e?.target && e.target.src !== PLACEHOLDER) {
e.target.src = PLACEHOLDER
}
}

async function toggleFavorite() {
const result = await courseService.toggleFavorite(props.course.id, securityStore.user.id)
isFavorite.value = result
Expand All @@ -69,10 +104,8 @@ async function toggleFavorite() {

watch(
() => props.course.userVote,
(newVote) => {
isFavorite.value = newVote === 1
},
{ immediate: true }
(newVote) => (isFavorite.value = newVote === 1),
{ immediate: true },
)
</script>

47 changes: 33 additions & 14 deletions assets/vue/components/course/CourseCard.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
<template>
<Card class="course-card">
<template #header>
<img
v-if="isLocked"
:alt="course.title"
:src="course.illustrationUrl || '/img/session_default.svg'"
/>
<BaseAppLink
v-else
:to="{ name: 'CourseHome', params: { id: course._id }, query: { sid: sessionId } }"
class="course-card__home-link"
>
<div class="relative aspect-[16/9] w-full overflow-hidden rounded-t-2xl bg-gray-100">
<img
:alt="course.title"
:src="course.illustrationUrl || '/img/session_default.svg'"
v-if="isLocked"
:alt="course.title || 'Course illustration'"
:src="imageUrl"
class="absolute inset-0 h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
/>
</BaseAppLink>
<BaseAppLink
v-else
:to="{ name: 'CourseHome', params: { id: course._id }, query: { sid: sessionId } }"
class="absolute inset-0 block"
aria-label="Open course"
>
<img
:alt="course.title || 'Course illustration'"
:src="imageUrl"
class="h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
/>
</BaseAppLink>
</div>
</template>
<template #title>
<div class="course-card__title flex items-center gap-2">
Expand Down Expand Up @@ -130,7 +139,9 @@ const daysRemainingText = computed(() => {
return t("Expired")
})

const showCourseDuration = computed(() => platformConfigStore.getSetting("course.show_course_duration") === "true")
const showCourseDuration = computed(
() => platformConfigStore.getSetting("course.show_course_duration") === "true",
)

const teachers = computed(() => {
if (props.session?.courseCoachesSubscriptions) {
Expand Down Expand Up @@ -172,6 +183,14 @@ const { hasRequirements, requirementList, graphImage, fetchStatus } = useCourseR

const isLocked = computed(() => props.disabled || internalLocked.value)

const imageUrl = computed(() =>
props.course?.illustrationUrl ||
props.course?.image?.url ||
props.course?.pictureUrl ||
props.course?.thumbnail ||
"/img/session_default.svg",
)

onMounted(() => {
if (props.course?.id) {
fetchStatus()
Expand Down
34 changes: 14 additions & 20 deletions assets/vue/views/sessionadmin/AdminDashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,15 @@
<div class="p-6">
<h1 class="text-2xl font-bold mb-6">{{ t("Available courses in this URL") }}</h1>

<div
v-if="loading"
class="text-gray-500"
>
<div v-if="loading" class="text-gray-500">
{{ t("Loading courses...") }}
</div>

<div
v-else-if="courses.length === 0"
class="text-gray-500"
>
<div v-else-if="courses.length === 0" class="text-gray-500">
{{ t("No courses available.") }}
</div>

<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-4 gap-6">
<AdminCourseCard
v-for="course in courses"
:key="course.id"
Expand Down Expand Up @@ -55,14 +46,17 @@ onMounted(async () => {
const favoriteIds =
favRes.status === "fulfilled" ? new Set(favRes.value.map((iri) => parseInt(iri.split("/").pop()))) : new Set()

courses.value = []
courses.value.push(
...allCourses.map((c) => ({
...c,
code: c.code ?? c.title,
userVote: favoriteIds.has(c.id) ? 1 : 0,
})),
)
courses.value = allCourses.map((c) => ({
...c,
code: c.code ?? c.title,
userVote: favoriteIds.has(c.id) ? 1 : 0,
illustrationUrl:
c?.illustrationUrl ||
c?.image?.url ||
c?.pictureUrl ||
c?.thumbnail ||
"/img/session_default.svg",
}))
}
} catch (e) {
console.warn("Error loading dashboard courses:", e)
Expand Down
68 changes: 44 additions & 24 deletions assets/vue/views/sessionadmin/FavoritesCourses.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,14 @@
<div class="p-6">
<h1 class="text-2xl font-bold mb-6">{{ t("Favorite courses") }}</h1>

<div
v-if="loading"
class="text-gray-500"
>
<div v-if="loading" class="text-gray-500">
{{ t("Loading") }}…
</div>
<div
v-else-if="favorites.length === 0"
class="text-gray-500"
>
<div v-else-if="favorites.length === 0" class="text-gray-500">
{{ t("No favorite courses.") }}
</div>

<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-4 gap-6">
<AdminCourseCard
v-for="c in favorites"
:key="c.id"
Expand All @@ -41,30 +32,59 @@ const favorites = ref([])
const loading = ref(true)
const securityStore = useSecurityStore()

function normalizeUrl(u) {
if (!u || typeof u !== "string") return null
const s = u.trim()
if (s.startsWith("http://") || s.startsWith("https://") || s.startsWith("/")) return s
return `/${s.replace(/^\/+/, "")}`
}

function resolveIllustration(c) {
const candidates = [
c.illustrationUrl,
c?.illustration?.url,
c?.illustration?.contentUrl,
c?.image?.url,
c.pictureUrl,
c.picture,
c.thumbnailUrl,
c.thumbnail,
c.coverUrl,
c?.cover?.url,
]
for (const cand of candidates) {
const n = normalizeUrl(cand)
if (n) return n
}
return "/img/session_default.svg"
}

async function loadFavorites() {
loading.value = true

const userId = securityStore.user.id
const isSessionAdmin = securityStore.isSessionAdmin

const favoritesRaw = await courseService.listFavoriteCourses(userId)
const courseIds = favoritesRaw.map((iri) => parseInt(iri.split("/").pop())).filter((id) => !isNaN(id))
const courseIds = favoritesRaw
.map((iri) => parseInt(iri.split("/").pop()))
.filter((id) => !isNaN(id))

const results = await Promise.allSettled(
courseIds.map((id) => (isSessionAdmin ? courseService.findCourseForSessionAdmin(id) : courseService.findById(id))),
courseIds.map((id) =>
isSessionAdmin ? courseService.findCourseForSessionAdmin(id) : courseService.findById(id),
),
)

favorites.value = results
.filter((r) => r.status === "fulfilled" && r.value !== null)
.map((r) => ({
...r.value,
userVote: 1,
}))

results
.filter((r) => r.status === "rejected")
.forEach((r, idx) => {
console.error(`Error loading favorite course ID ${courseIds[idx]}:`, r.reason)
.filter((r) => r.status === "fulfilled" && r.value)
.map((r) => {
const c = r.value
return {
...c,
userVote: 1,
illustrationUrl: resolveIllustration(c),
}
})

loading.value = false
Expand Down
Loading
Loading