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) => { const handleLessonSelect = (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return if (currentLesson.value?.id === lessonId) return
const url = new URL(window.location.href)
url.searchParams.set('lesson_id', lessonId.toString()) // 1. URL
resetAndNavigate(url.toString()) 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) => { const handleExit = (path: string) => {
resetAndNavigate(path) resetAndNavigate(path)
} }
@ -155,24 +163,25 @@ const handleExit = (path: string) => {
const loadCourseData = async () => { const loadCourseData = async () => {
if (!courseId.value) return if (!courseId.value) return
isLoading.value = true isLoading.value = true
// Reset states before loading new course
courseData.value = null
currentLesson.value = null
announcements.value = []
try { try {
const res = await fetchCourseLearningInfo(courseId.value) const res = await fetchCourseLearningInfo(courseId.value)
if (res.success) { if (res.success) {
courseData.value = res.data courseData.value = res.data
// Auto-load logic: // Auto-load logic: URL
if (!currentLesson.value) { const urlLessonId = route.query.lesson_id ? Number(route.query.lesson_id) : null
const firstChapter = res.data.chapters[0]
if (firstChapter && firstChapter.lessons.length > 0) { if (urlLessonId) {
// Find first unlocked or just first // URL
const availableLesson = firstChapter.lessons.find((l: any) => !l.is_locked) || firstChapter.lessons[0] loadLesson(urlLessonId)
loadLesson(availableLesson.id) } 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 // Fetch Announcements
@ -507,7 +516,7 @@ onBeforeUnmount(() => {
<!-- Header --> <!-- 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-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-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 --> <!-- Back Button & Branding -->
<div class="flex items-center gap-2 mr-6"> <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> <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) --> <!-- 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 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-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 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" /> <q-icon name="quiz" size="40px" />
</div> </div>
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle', 'แบบทดสอบท้ายบทเรียน') }}</h2> <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> <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"> <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-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?.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">
<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> <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> </div>
<q-btn <q-btn

View file

@ -65,7 +65,7 @@ const getQuestionStatusClass = (index: number, questionId: number) => {
} }
// 4. Not Started = Grey // 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) => { const jumpToQuestion = (targetIndex: number) => {
@ -377,7 +377,7 @@ const getCorrectChoiceId = (questionId: number) => {
<div class="flex justify-center mb-10"> <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"> <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>
</div> </div>
@ -466,25 +466,7 @@ const getCorrectChoiceId = (questionId: number) => {
</button> </button>
</div> </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 --> <!-- Controls -->
<div class="flex justify-between items-center pt-8 border-t border-slate-100 dark:border-white/5"> <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="{ :class="{
'border-emerald-500 bg-emerald-50 dark:bg-emerald-500/10': choice.id === getCorrectChoiceId(question.id), '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-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 --> <!-- Indicator Icon -->