elearning/Frontend-Learner/components/classroom/CurriculumSidebar.vue
supalerk-ar66 e3873f616e
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
feat: Add initial pages and components for user dashboard, profile, course discovery, and classroom learning with i18n support.
2026-02-20 14:58:18 +07:00

231 lines
10 KiB
Vue

<script setup lang="ts">
/**
* @file CurriculumSidebar.vue
* @description Sidebar Component for displaying course curriculum (Chapters & Lessons)
* Handles lesson navigation, locked status display, and unread announcement badge.
*/
const props = defineProps<{
modelValue: boolean; // Sidebar open state (v-model)
courseData: any;
currentLessonId?: number;
isLoading: boolean;
hasUnreadAnnouncements: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'select-lesson', lessonId: number): void;
(e: 'open-announcements'): void;
}>();
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
// 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
let total = 0
let completed = 0
props.courseData.chapters.forEach((c: any) => {
c.lessons.forEach((l: any) => {
total++
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>
<q-drawer
:model-value="modelValue"
@update:model-value="(val) => emit('update:modelValue', val)"
show-if-above
bordered
side="right"
:width="300"
:breakpoint="1024"
class="bg-slate-50 dark:bg-slate-900 shadow-xl"
>
<!-- 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-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"
:style="{ width: `${progressPercentage}%` }"
></div>
</div>
</div>
<!-- 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>
<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>
</div>
</div>
</div>
</q-expansion-item>
</div>
</q-list>
</div>
</div>
<div v-else-if="isLoading" class="p-6 text-center text-slate-500">
<q-spinner color="primary" size="2em" />
<div class="mt-2 text-xs">{{ $t('classroom.loadingCurriculum') }}</div>
</div>
</q-drawer>
</template>
<style scoped>
/* Custom scrollbar for better aesthetics */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
</style>