feat: Add initial pages and components for user dashboard, profile, course discovery, and classroom learning with i18n support.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 47s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 47s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
This commit is contained in:
parent
f26a94076c
commit
e3873f616e
11 changed files with 1046 additions and 685 deletions
|
|
@ -21,15 +21,40 @@ const emit = defineEmits<{
|
|||
|
||||
const { locale } = useI18n()
|
||||
|
||||
// State for expansion items
|
||||
const chapterOpenState = ref<Record<string, boolean>>({})
|
||||
|
||||
// Helper for localization
|
||||
const getLocalizedText = (text: any) => {
|
||||
if (!text) return ''
|
||||
if (typeof text === 'string') return text
|
||||
|
||||
const currentLocale = locale.value as 'th' | 'en'
|
||||
// Safe locale access
|
||||
const currentLocale = (locale?.value || 'th') as 'th' | 'en'
|
||||
return text[currentLocale] || text.th || text.en || ''
|
||||
}
|
||||
|
||||
// Helper: Check if lesson is completed
|
||||
const isLessonCompleted = (lesson: any) => {
|
||||
return lesson.is_completed === true || lesson.progress?.is_completed === true
|
||||
}
|
||||
|
||||
// Reactive Chapter Completion Status
|
||||
// Computes a map of chapterId -> boolean (true if all lessons are completed)
|
||||
const chapterCompletionStatus = computed(() => {
|
||||
const status: Record<string, boolean> = {}
|
||||
if (!props.courseData || !props.courseData.chapters) return status
|
||||
|
||||
props.courseData.chapters.forEach((chapter: any) => {
|
||||
if (chapter.lessons && chapter.lessons.length > 0) {
|
||||
status[chapter.id] = chapter.lessons.every((l: any) => isLessonCompleted(l))
|
||||
} else {
|
||||
status[chapter.id] = false
|
||||
}
|
||||
})
|
||||
return status
|
||||
})
|
||||
|
||||
// Local Progress Calculation
|
||||
const progressPercentage = computed(() => {
|
||||
if (!props.courseData || !props.courseData.chapters) return 0
|
||||
|
|
@ -38,11 +63,34 @@ const progressPercentage = computed(() => {
|
|||
props.courseData.chapters.forEach((c: any) => {
|
||||
c.lessons.forEach((l: any) => {
|
||||
total++
|
||||
if (l.is_completed || l.progress?.is_completed) completed++
|
||||
if (isLessonCompleted(l)) completed++
|
||||
})
|
||||
})
|
||||
return total > 0 ? Math.round((completed / total) * 100) : 0
|
||||
})
|
||||
|
||||
// Auto-expand chapter containing current lesson
|
||||
watch(() => props.currentLessonId, (newId) => {
|
||||
if (newId && props.courseData?.chapters) {
|
||||
props.courseData.chapters.forEach((chapter: any) => {
|
||||
const hasLesson = chapter.lessons.some((l: any) => l.id === newId)
|
||||
if (hasLesson) {
|
||||
chapterOpenState.value[chapter.id] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Initialize all chapters as open by default on load
|
||||
watch(() => props.courseData, (newData) => {
|
||||
if (newData?.chapters) {
|
||||
newData.chapters.forEach((chapter: any) => {
|
||||
if (chapterOpenState.value[chapter.id] === undefined) {
|
||||
chapterOpenState.value[chapter.id] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -51,71 +99,111 @@ const progressPercentage = computed(() => {
|
|||
@update:model-value="(val) => emit('update:modelValue', val)"
|
||||
show-if-above
|
||||
bordered
|
||||
side="left"
|
||||
:width="280"
|
||||
side="right"
|
||||
:width="300"
|
||||
:breakpoint="1024"
|
||||
class="bg-slate-50 dark:bg-slate-900 shadow-xl"
|
||||
content-class="flex flex-col h-full"
|
||||
>
|
||||
<div v-if="courseData" class="flex flex-col h-full overflow-hidden">
|
||||
<!-- Course Progress Header -->
|
||||
<div class="p-5 border-b border-gray-200 dark:border-white/10 bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<!-- Main Container: Enforce Column Layout and Full Width -->
|
||||
<div v-if="courseData" class="flex flex-col w-full h-full overflow-hidden text-slate-900 dark:text-white relative">
|
||||
|
||||
<!-- 1. Header Section (Fixed at Top) -->
|
||||
<div class="flex-none p-5 border-b border-slate-200 dark:border-white/10 bg-white dark:bg-slate-900 z-10 w-full">
|
||||
<h2 class="text-sm font-bold mb-4 line-clamp-2 leading-snug block w-full">{{ getLocalizedText(courseData.course.title) }}</h2>
|
||||
|
||||
<div class="flex justify-between items-center mb-2 w-full">
|
||||
<span class="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
|
||||
<span class="text-sm font-black text-blue-600 dark:text-blue-400">{{ progressPercentage }}%</span>
|
||||
</div>
|
||||
<div class="h-2 w-full bg-slate-200 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
|
||||
<div class="h-2 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out shadow-[0_0_12px_rgba(37,99,235,0.3)]"
|
||||
class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out"
|
||||
:style="{ width: `${progressPercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow scroll">
|
||||
<q-list padding class="py-2">
|
||||
<!-- 2. Curriculum List (Scrollable Area) -->
|
||||
<div class="flex-1 overflow-y-auto bg-slate-50 dark:bg-[#0f1219] w-full p-4 space-y-3">
|
||||
<q-list class="block w-full">
|
||||
<div v-for="(chapter, idx) in courseData.chapters" :key="chapter.id" class="block w-full mb-3">
|
||||
<!-- Chapter Accordion -->
|
||||
<q-expansion-item
|
||||
v-model="chapterOpenState[chapter.id]"
|
||||
class="bg-white dark:bg-[#1a1e29] rounded-xl overflow-hidden shadow-sm border border-slate-200 dark:border-slate-800 w-full"
|
||||
header-class="rounded-t-xl w-full"
|
||||
expand-icon-class="text-slate-400"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<div class="flex items-center w-full py-3 text-slate-900 dark:text-white">
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<!-- Chapter Indicator (Check or Number) -->
|
||||
<div class="w-7 h-7 rounded-full border-2 flex items-center justify-center transition-colors font-bold"
|
||||
:class="chapterCompletionStatus[chapter.id]
|
||||
? 'border-green-500 text-green-500 bg-green-50 dark:bg-green-500/10'
|
||||
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-800'">
|
||||
<q-icon v-if="chapterCompletionStatus[chapter.id]" name="check" size="14px" class="font-bold" />
|
||||
<span v-else class="text-[10px]">{{ Number(idx) + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Explicitly handle text overflow -->
|
||||
<div class="flex-1 min-w-0 pr-2 overflow-hidden">
|
||||
<div class="font-bold text-sm leading-tight mb-0.5 truncate block w-full">{{ getLocalizedText(chapter.title) }}</div>
|
||||
<div class="text-[10px] text-slate-500 dark:text-slate-400 font-normal truncate block w-full">
|
||||
{{ chapter.lessons.length }} {{ $t('course.lessonsUnit') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Lessons List -->
|
||||
<div class="bg-slate-50 dark:bg-[#0f1219]/50 border-t border-slate-100 dark:border-slate-800 w-full">
|
||||
<div
|
||||
v-for="(lesson, lIdx) in chapter.lessons"
|
||||
:key="lesson.id"
|
||||
class="flex items-center px-4 py-3 cursor-pointer transition-all border-l-4 hover:bg-slate-100 dark:hover:bg-slate-800/50 w-full"
|
||||
:class="currentLessonId === lesson.id
|
||||
? 'border-blue-600 bg-blue-50 dark:bg-blue-900/10'
|
||||
: 'border-transparent'"
|
||||
@click="!lesson.is_locked && emit('select-lesson', lesson.id)"
|
||||
>
|
||||
<!-- Lesson Status Icon -->
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<!-- Completed (Takes Precedence) -->
|
||||
<q-icon v-if="isLessonCompleted(lesson)"
|
||||
name="check_circle"
|
||||
class="text-green-500"
|
||||
size="20px"
|
||||
/>
|
||||
<!-- Active/Playing (If not completed) -->
|
||||
<q-icon v-else-if="currentLessonId === lesson.id"
|
||||
name="play_circle_filled"
|
||||
class="text-blue-600 dark:text-blue-400 animate-pulse"
|
||||
size="20px"
|
||||
/>
|
||||
<!-- Locked -->
|
||||
<q-icon v-else-if="lesson.is_locked"
|
||||
name="lock"
|
||||
class="text-slate-400 opacity-70"
|
||||
size="18px"
|
||||
/>
|
||||
<!-- Not Started -->
|
||||
<div v-else class="w-[18px] h-[18px] rounded-full border-2 border-slate-300 dark:border-slate-600"></div>
|
||||
</div>
|
||||
|
||||
<template v-for="chapter in courseData.chapters" :key="chapter.id">
|
||||
<q-item-label header class="bg-slate-100 dark:bg-slate-800 text-[var(--text-main)] font-bold sticky top-0 z-10 border-b dark:border-white/5 text-sm py-4">
|
||||
{{ getLocalizedText(chapter.title) }}
|
||||
</q-item-label>
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<div class="text-xs font-bold truncate leading-snug block w-full"
|
||||
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-600 dark:text-slate-300'"
|
||||
>
|
||||
{{ getLocalizedText(lesson.title) }}
|
||||
</div>
|
||||
|
||||
<q-item
|
||||
v-for="lesson in chapter.lessons"
|
||||
:key="lesson.id"
|
||||
clickable
|
||||
v-ripple
|
||||
:active="currentLessonId === lesson.id"
|
||||
active-class="bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 active-lesson-indicator"
|
||||
class="px-5 py-3 transition-all duration-200 group relative border-b border-gray-100/50 dark:border-white/5"
|
||||
@click="!lesson.is_locked && emit('select-lesson', lesson.id)"
|
||||
:disable="lesson.is_locked"
|
||||
>
|
||||
<q-item-section avatar v-if="lesson.is_locked">
|
||||
<q-icon name="lock" size="xs" color="grey" />
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
class="text-sm font-bold line-clamp-2 transition-colors"
|
||||
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-700 dark:text-slate-300'"
|
||||
>
|
||||
{{ getLocalizedText(lesson.title) }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<div class="flex items-center">
|
||||
<q-icon v-if="lesson.is_completed || lesson.progress?.is_completed" name="check_circle" color="positive" size="18px" />
|
||||
<q-icon v-else-if="currentLessonId === lesson.id" name="play_arrow" color="primary" size="18px" class="animate-pulse" />
|
||||
<q-icon v-else-if="lesson.is_locked" name="lock" color="grey-4" size="18px" />
|
||||
<q-icon v-else name="radio_button_unchecked" color="grey-3" size="18px" />
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isLoading" class="p-6 text-center text-slate-500">
|
||||
|
|
@ -126,31 +214,18 @@ const progressPercentage = computed(() => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
.active-lesson-indicator {
|
||||
position: relative;
|
||||
}
|
||||
.active-lesson-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #2563eb; /* blue-600 */
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.scroll::-webkit-scrollbar {
|
||||
/* Custom scrollbar for better aesthetics */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.scroll::-webkit-scrollbar-track {
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 10px;
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dark .scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue