Compare commits

...

2 commits

Author SHA1 Message Date
supalerk-ar66
220dc0148d feat: add classroom learning interface with video playback, progress tracking, and a dedicated quiz page.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 52s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-10 13:14:06 +07:00
supalerk-ar66
350e3c27b3 feat: Add new classroom learning page with course content display, video playback, progress tracking, lesson access control, and course announcements. 2026-02-10 13:14:06 +07:00
2 changed files with 40 additions and 45 deletions

View file

@ -133,15 +133,23 @@ const resetAndNavigate = (path: string) => {
}
}
// Logic loadLesson
// Logic loadLesson SPA
const handleLessonSelect = (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return
const url = new URL(window.location.href)
url.searchParams.set('lesson_id', lessonId.toString())
resetAndNavigate(url.toString())
// 1. URL
router.push({ query: { ...route.query, lesson_id: lessonId.toString() } })
// 2. Refresh
loadLesson(lessonId)
// Sidebar
if (sidebarOpen.value) {
sidebarOpen.value = false
}
}
// Logic
// Logic ( Hard Reload )
const handleExit = (path: string) => {
resetAndNavigate(path)
}
@ -155,24 +163,25 @@ const handleExit = (path: string) => {
const loadCourseData = async () => {
if (!courseId.value) return
isLoading.value = true
// Reset states before loading new course
courseData.value = null
currentLesson.value = null
announcements.value = []
try {
const res = await fetchCourseLearningInfo(courseId.value)
if (res.success) {
courseData.value = res.data
// Auto-load logic:
if (!currentLesson.value) {
const firstChapter = res.data.chapters[0]
if (firstChapter && firstChapter.lessons.length > 0) {
// Find first unlocked or just first
const availableLesson = firstChapter.lessons.find((l: any) => !l.is_locked) || firstChapter.lessons[0]
loadLesson(availableLesson.id)
}
// Auto-load logic: URL
const urlLessonId = route.query.lesson_id ? Number(route.query.lesson_id) : null
if (urlLessonId) {
// URL
loadLesson(urlLessonId)
} else if (!currentLesson.value) {
// URL
const firstChapter = res.data.chapters[0]
if (firstChapter && firstChapter.lessons.length > 0) {
const availableLesson = firstChapter.lessons.find((l: any) => !l.is_locked) || firstChapter.lessons[0]
loadLesson(availableLesson.id)
}
}
// Fetch Announcements
@ -507,7 +516,7 @@ onBeforeUnmount(() => {
<!-- Header -->
<q-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-14">
<q-toolbar>
<q-btn flat round dense icon="menu" class="lg:hidden mr-2 text-slate-900 dark:text-white" @click="toggleSidebar" />
<q-btn flat round dense icon="menu" class="mr-2 text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" @click="toggleSidebar" />
<!-- Back Button & Branding -->
<div class="flex items-center gap-2 mr-6">
@ -597,16 +606,20 @@ onBeforeUnmount(() => {
<p class="text-slate-600 dark:text-slate-400 text-base md:text-lg leading-relaxed mb-6" v-if="currentLesson.description">{{ getLocalizedText(currentLesson.description) }}</p>
<!-- Lesson Content Area (Text/HTML) -->
<div v-if="currentLesson.type === 'QUIZ'" class="p-8 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-slate-800 dark:to-slate-800/50 rounded-2xl border border-blue-100 dark:border-white/5 text-center">
<div class="bg-white dark:bg-slate-700 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm text-blue-500 dark:text-blue-300">
<div v-if="currentLesson.type === 'QUIZ'" class="p-8 bg-gradient-to-br from-blue-50/50 to-indigo-50/50 dark:from-slate-800/50 dark:to-slate-900/50 rounded-2xl border border-blue-100 dark:border-white/5 text-center">
<div class="bg-white dark:bg-slate-800 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm text-blue-500 dark:text-blue-400 border dark:border-white/10">
<q-icon name="quiz" size="40px" />
</div>
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle', 'แบบทดสอบท้ายบทเรียน') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-6 max-w-md mx-auto">{{ getLocalizedText(currentLesson.quiz?.description || currentLesson.description) }}</p>
<div class="flex justify-center flex-wrap gap-3 text-sm text-slate-600 dark:text-slate-300 mb-8">
<span v-if="currentLesson.quiz?.questions?.length" class="px-3 py-1 bg-white dark:bg-slate-900 rounded-full border border-gray-200 dark:border-white/10 shadow-sm flex items-center gap-1.5"><q-icon name="format_list_numbered" size="14px" class="text-blue-500" /> {{ currentLesson.quiz.questions.length }} </span>
<span v-if="currentLesson.quiz?.time_limit" class="px-3 py-1 bg-white dark:bg-slate-900 rounded-full border border-gray-200 dark:border-white/10 shadow-sm flex items-center gap-1.5"><q-icon name="schedule" size="14px" class="text-orange-500" /> {{ currentLesson.quiz.time_limit }} นาท</span>
<div class="flex justify-center flex-wrap gap-3 text-sm mb-8">
<span v-if="currentLesson.quiz?.questions?.length" class="px-3 py-1 bg-white dark:bg-white rounded-full border border-gray-200 dark:border-white/10 shadow-sm flex items-center gap-1.5 text-slate-900 font-bold">
<q-icon name="format_list_numbered" size="14px" class="text-blue-600" /> {{ currentLesson.quiz.questions.length }}
</span>
<span v-if="currentLesson.quiz?.time_limit" class="px-3 py-1 bg-white dark:bg-white rounded-full border border-gray-200 dark:border-white/10 shadow-sm flex items-center gap-1.5 text-slate-900 font-bold">
<q-icon name="schedule" size="14px" class="text-orange-600" /> {{ currentLesson.quiz.time_limit }} นาท
</span>
</div>
<q-btn

View file

@ -65,7 +65,7 @@ const getQuestionStatusClass = (index: number, questionId: number) => {
}
// 4. Not Started = Grey
return 'bg-slate-200 text-slate-400 border-slate-300 dark:bg-white/5 dark:border-white/10 dark:text-slate-500 hover:bg-slate-300 dark:hover:bg-white/10'
return 'bg-slate-200 text-slate-400 border-slate-300 dark:bg-white/5 dark:border-white/5 dark:text-slate-600 hover:bg-slate-300 dark:hover:bg-white/10'
}
const jumpToQuestion = (targetIndex: number) => {
@ -377,7 +377,7 @@ const getCorrectChoiceId = (questionId: number) => {
<div class="flex justify-center mb-10">
<div class="w-20 h-20 rounded-3xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 flex items-center justify-center shadow-inner">
<q-icon name="quiz" size="2rem" color="primary" />
<q-icon name="quiz" size="2.5rem" color="primary" class="dark:text-blue-400" />
</div>
</div>
@ -466,25 +466,7 @@ const getCorrectChoiceId = (questionId: number) => {
</button>
</div>
<div class="mb-8 px-2">
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">
{{ $t('quiz.statusLabel', 'สถานะข้อสอบ') }}
</div>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2 text-sm font-medium text-slate-500 dark:text-slate-400">
<div class="w-2.5 h-2.5 rounded-full bg-blue-500 ring-2 ring-blue-100 dark:ring-blue-900/30"></div> {{ $t('quiz.statusCurrent', 'Current') }}
</div>
<div class="flex items-center gap-2 text-sm font-medium text-slate-500 dark:text-slate-400">
<div class="w-2.5 h-2.5 rounded-full bg-emerald-500"></div> {{ $t('quiz.statusCompleted', 'Completed') }}
</div>
<div class="flex items-center gap-2 text-sm font-medium text-slate-500 dark:text-slate-400">
<div class="w-2.5 h-2.5 rounded-full bg-orange-500"></div> {{ $t('quiz.statusSkipped', 'Skipped') }}
</div>
<div class="flex items-center gap-2 text-sm font-medium text-slate-500 dark:text-slate-400">
<div class="w-2.5 h-2.5 rounded-full bg-slate-200 dark:bg-slate-700"></div> {{ $t('quiz.statusNotStarted', 'Not Started') }}
</div>
</div>
</div>
<!-- Controls -->
<div class="flex justify-between items-center pt-8 border-t border-slate-100 dark:border-white/5">
@ -602,7 +584,7 @@ const getCorrectChoiceId = (questionId: number) => {
:class="{
'border-emerald-500 bg-emerald-50 dark:bg-emerald-500/10': choice.id === getCorrectChoiceId(question.id),
'border-red-500 bg-red-50 dark:bg-red-500/10': userAnswers[question.id] === choice.id && choice.id !== getCorrectChoiceId(question.id),
'border-slate-100 dark:border-white/5 opacity-60': userAnswers[question.id] !== choice.id && choice.id !== getCorrectChoiceId(question.id)
'border-slate-100 dark:border-white/5 opacity-80 dark:opacity-40': userAnswers[question.id] !== choice.id && choice.id !== getCorrectChoiceId(question.id)
}"
>
<!-- Indicator Icon -->