- CourseCard: raise image from h-40 to h-52 (less cropping) - CourseCard: use -webkit-line-clamp inline style to hard-clip title overflow - CourseCard: reduce footer bottom padding (pb-1) - StreamEmbed: pass both current hostname and localhost as Twitch parent params to fix error 2000 in local/dev environments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
6.1 KiB
Vue
200 lines
6.1 KiB
Vue
<template>
|
|
<div
|
|
v-if="course.title"
|
|
class="flex flex-col h-full rounded-xl overflow-hidden border border-outline-gray-2 bg-surface-white transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 group"
|
|
style="min-height: 320px"
|
|
>
|
|
<!-- Cover image / gradient -->
|
|
<div
|
|
class="relative w-full h-52 bg-cover bg-center bg-no-repeat flex-shrink-0"
|
|
:style="
|
|
course.image
|
|
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
|
: { backgroundImage: getGradientColor() }
|
|
"
|
|
>
|
|
<!-- Top badges row -->
|
|
<div class="absolute top-2.5 left-2.5 flex flex-wrap gap-1">
|
|
<span
|
|
v-if="course.upcoming"
|
|
class="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-surface-amber-2 text-ink-amber-3 border border-outline-amber-1"
|
|
>
|
|
<Clock class="h-2.5 w-2.5" />
|
|
{{ __('Upcoming') }}
|
|
</span>
|
|
<span
|
|
v-else-if="isNew"
|
|
class="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-surface-green-1 text-ink-green-3 border border-outline-green-1"
|
|
>
|
|
<Sparkles class="h-2.5 w-2.5" />
|
|
{{ __('New') }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="absolute top-2.5 right-2.5 flex items-center gap-1">
|
|
<Tooltip v-if="course.featured" :text="__('Featured')">
|
|
<span class="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-surface-amber-2 text-ink-amber-3 border border-outline-amber-1">
|
|
<Star class="h-2.5 w-2.5 stroke-2" />
|
|
</span>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<!-- Title fallback when no image -->
|
|
<div
|
|
v-if="!course.image"
|
|
class="flex items-end h-full px-4 pb-3"
|
|
>
|
|
<h3
|
|
class="text-white font-bold leading-tight drop-shadow"
|
|
:class="course.title.length > 40 ? 'text-base' : course.title.length > 24 ? 'text-lg' : 'text-xl'"
|
|
>
|
|
{{ course.title }}
|
|
</h3>
|
|
</div>
|
|
|
|
<!-- Progress overlay -->
|
|
<div
|
|
v-if="user && course.membership"
|
|
class="absolute bottom-0 left-0 right-0 h-1 bg-black/20"
|
|
>
|
|
<div
|
|
class="h-full bg-ink-green-3 transition-all duration-500"
|
|
:style="{ width: Math.ceil(course.membership.progress) + '%' }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Card body -->
|
|
<div class="flex flex-col flex-1 p-4">
|
|
<!-- Title (when image present): max 2 lines -->
|
|
<div
|
|
v-if="course.image"
|
|
class="font-semibold text-ink-gray-9 leading-snug mb-1 text-sm"
|
|
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 2.5rem"
|
|
>
|
|
{{ course.title }}
|
|
</div>
|
|
|
|
<!-- Short intro: clamp to 2 lines, fixed height -->
|
|
<p class="text-xs text-ink-gray-5 leading-relaxed line-clamp-2 mt-0.5" style="height: 2.5rem; overflow: hidden">
|
|
{{ course.short_introduction }}
|
|
</p>
|
|
|
|
<!-- Spacer: pushes stats + footer to the bottom -->
|
|
<div class="flex-1" />
|
|
|
|
<!-- Stats row -->
|
|
<div class="flex items-center gap-3 text-xs text-ink-gray-5 mb-3 flex-wrap">
|
|
<span v-if="course.lessons" class="flex items-center gap-1">
|
|
<BookOpen class="h-3.5 w-3.5" />
|
|
{{ course.lessons }} {{ __('lessons') }}
|
|
</span>
|
|
<span v-if="course.enrollments" class="flex items-center gap-1">
|
|
<Users class="h-3.5 w-3.5" />
|
|
{{ formatAmount(course.enrollments) }}
|
|
</span>
|
|
<span v-if="course.rating" class="flex items-center gap-1 text-ink-amber-3">
|
|
<Star class="h-3.5 w-3.5 fill-current" />
|
|
{{ course.rating }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex items-center justify-between pt-2 pb-1 border-t border-outline-gray-1">
|
|
<!-- Instructors -->
|
|
<div class="flex items-center gap-1.5">
|
|
<div class="flex -space-x-1.5">
|
|
<UserAvatar
|
|
v-for="instructor in course.instructors.slice(0, 3)"
|
|
:key="instructor.name"
|
|
:user="instructor"
|
|
class="ring-1 ring-white"
|
|
/>
|
|
</div>
|
|
<CourseInstructors
|
|
v-if="course.instructors.length === 1"
|
|
:instructors="course.instructors"
|
|
class="text-xs text-ink-gray-5"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Right side: progress % / price / cert -->
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
v-if="user && course.membership"
|
|
class="text-xs font-medium text-ink-green-3"
|
|
>
|
|
{{ Math.ceil(course.membership.progress) }}%
|
|
</span>
|
|
<span v-else-if="course.paid_course" class="text-sm font-semibold text-ink-gray-9">
|
|
{{ course.price }}
|
|
</span>
|
|
<Tooltip
|
|
v-if="course.paid_certificate || course.enable_certification"
|
|
:text="__('Get Certified')"
|
|
>
|
|
<span class="inline-flex items-center gap-0.5 text-[10px] font-medium text-ink-blue-3 bg-surface-blue-1 px-1.5 py-0.5 rounded border border-outline-blue-1">
|
|
<GraduationCap class="h-3 w-3" />
|
|
</span>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed } from 'vue'
|
|
import {
|
|
Award,
|
|
BookOpen,
|
|
Clock,
|
|
GraduationCap,
|
|
Sparkles,
|
|
Star,
|
|
Users,
|
|
} from 'lucide-vue-next'
|
|
import { sessionStore } from '@/stores/session'
|
|
import { Tooltip } from 'frappe-ui'
|
|
import { formatAmount } from '@/utils'
|
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
|
import UserAvatar from '@/components/UserAvatar.vue'
|
|
import colors from '@/utils/frappe-ui-colors.json'
|
|
import dayjs from '@/utils/dayjs'
|
|
|
|
const { user } = sessionStore()
|
|
|
|
const props = defineProps({
|
|
course: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
})
|
|
|
|
// Course is "new" if published in the last 60 days
|
|
const isNew = computed(() => {
|
|
if (!props.course.published_on) return false
|
|
return dayjs().diff(dayjs(props.course.published_on), 'day') <= 60
|
|
})
|
|
|
|
const getGradientColor = () => {
|
|
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
|
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
|
let colorMap = colors[theme][color]
|
|
return `linear-gradient(to bottom right, #111827, ${colorMap[500]})`
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.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>
|