feat: Implement 'My Courses' dashboard, including course cards, classroom learning, and quiz pages.

This commit is contained in:
supalerk-ar66 2026-01-21 17:03:09 +07:00
parent fc3e2820cc
commit f6bbd60f2b
5 changed files with 111 additions and 74 deletions

View file

@ -7,6 +7,8 @@
*/
interface CourseCardProps {
/** Course ID for navigation */
id?: number
/** Course Title */
title: string | { th: string; en: string }
/** Difficulty Level (Beginner, Intermediate, etc.) */
@ -124,12 +126,12 @@ const displayDescription = computed(() => getLocalizedText(props.description))
{{ $t('menu.viewDetails') }}
</button>
<NuxtLink v-if="showContinue" to="/classroom/learning" class="btn-premium-primary w-full mt-auto shadow-lg shadow-blue-600/20">
<NuxtLink v-if="showContinue" :to="`/classroom/learning?course_id=${id}`" class="btn-premium-primary w-full mt-auto shadow-lg shadow-blue-600/20">
{{ $t('course.continueLearning') }}
</NuxtLink>
<div v-if="completed && (showCertificate || showStudyAgain)" class="flex flex-col gap-2 mt-auto">
<NuxtLink v-if="showStudyAgain" to="/classroom/learning" class="btn-premium-primary w-full dark:!text-white">
<NuxtLink v-if="showStudyAgain" :to="`/classroom/learning?course_id=${id}`" class="btn-premium-primary w-full dark:!text-white">
{{ $t('course.studyAgain') }}
</NuxtLink>
<button v-if="showCertificate" class="btn-premium-success w-full shadow-lg shadow-emerald-600/20" @click="emit('viewCertificate')">

View file

@ -153,19 +153,39 @@ const updateProgress = () => {
// but let's check if we should save periodically here for simplicity or use specific events
}
const videoSrc = computed(() => {
if (!currentLesson.value) return ''
const content = getLocalizedText(currentLesson.value.content)
// Check if content looks like a URL (starts with http/https or /)
// And doesn't contain obvious text indicators
if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) {
return content
}
return ''
})
// Save progress periodically
watch(() => isPlaying.value, (playing) => {
if (playing) {
saveProgressInterval.value = setInterval(() => {
if (videoRef.value && currentLesson.value) {
saveVideoProgress(currentLesson.value.id, videoRef.value.currentTime, videoRef.value.duration)
// Send integers to avoid potential backend float issues
saveVideoProgress(
currentLesson.value.id,
Math.floor(videoRef.value.currentTime),
Math.floor(videoRef.value.duration || 0)
)
}
}, 10000) // Every 10 seconds
} else {
clearInterval(saveProgressInterval.value)
// Save one last time on pause
if (videoRef.value && currentLesson.value) {
saveVideoProgress(currentLesson.value.id, videoRef.value.currentTime, videoRef.value.duration)
saveVideoProgress(
currentLesson.value.id,
Math.floor(videoRef.value.currentTime),
Math.floor(videoRef.value.duration || 0)
)
}
}
})
@ -230,10 +250,10 @@ onBeforeUnmount(() => {
<div class="flex items-center gap-2 md:gap-10 pr-2 md:pr-0">
<div class="flex items-center gap-2 md:gap-3" v-if="courseData">
<span class="text-[10px] font-bold text-slate-700 dark:text-slate-400 whitespace-nowrap">
<span class="hidden md:inline">เรยนจบแล </span>{{ courseData.enrollment.progress_percentage || 0 }}%
<span class="hidden md:inline">เรยนจบแล </span>0%
</span>
<div class="w-12 md:w-32 h-1 bg-white/10 rounded-full overflow-hidden flex-shrink-0">
<div class="h-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.3)]" :style="{ width: (courseData.enrollment.progress_percentage || 0) + '%' }"/>
<div class="h-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.3)]" style="width: 0%"/>
</div>
</div>
</div>
@ -242,18 +262,6 @@ onBeforeUnmount(() => {
<!-- Sidebar: Course Curriculum list -->
<aside class="learning-sidebar dark:!bg-gray-900 dark:!border-r-white/5 transition-colors" :class="{ 'open': sidebarOpen }">
<div class="py-2" v-if="courseData">
<!-- Announcements Tab Trigger -->
<div
class="lesson-item group"
:class="{ 'active-tab': activeTab === 'announcements' }"
@click="switchTab('announcements')"
>
<div class="flex items-center gap-3">
<span class="text-lg" style="color: #ff3366;">📢</span>
<span class="font-black text-[12px] tracking-tight dark:!text-white">ประกาศในคอร</span>
</div>
</div>
<!-- Chapters & Lessons List -->
<div v-for="chapter in courseData.chapters" :key="chapter.id" class="mt-4">
<div class="chapter-header px-4 py-2 dark:!bg-slate-900 dark:!text-white dark:!border-b-white/5">
@ -282,15 +290,6 @@ onBeforeUnmount(() => {
</span>
</div>
</div>
<!-- Exam Link (Mock for now, can be integrated later) -->
<div class="mt-8 border-t border-white/5 pt-2">
<div class="chapter-header px-4 py-2 uppercase tracking-widest text-[10px] dark:!bg-slate-900 dark:!text-white dark:!border-b-white/5">แบบทดสอบ</div>
<NuxtLink to="/classroom/quiz" class="lesson-item px-4 no-underline cursor-pointer">
<span class="text-[12px] font-medium text-slate-900 dark:text-slate-200 group-hover:text-black dark:group-hover:text-white transition-colors">อสอบปลายภาค</span>
<span class="text-xs opacity-50">📄</span>
</NuxtLink>
</div>
</div>
<div v-else-if="isLoading" class="p-6 text-center text-slate-500 text-sm">
กำลงโหลดเนอหา...
@ -306,15 +305,7 @@ onBeforeUnmount(() => {
<!-- Tab Content: Announcements (Center Aligned) -->
<div v-if="activeTab === 'announcements'" class="animate-fade-in max-w-4xl mx-auto space-y-8 pt-8 md:pt-14">
<h2 class="text-2xl md:text-[28px] font-black text-slate-900 dark:text-white tracking-tight">ประกาศทงหมดในคอรสน</h2>
<div class="p-8 md:p-10 bg-slate-100 dark:bg-slate-900/40 ring-1 ring-slate-300 dark:ring-white/10 border-l-4 border-l-amber-500 rounded-2xl relative shadow-md dark:shadow-2xl dark:backdrop-blur-md transition-colors">
<div class="absolute top-4 right-8 text-amber-500 text-[10px] font-bold uppercase tracking-widest hidden sm:block">📌 กหม</div>
<div class="flex items-center gap-3 mb-6">
<span class="bg-amber-100/10 text-amber-500 px-3 py-1 rounded-lg text-[10px] font-black ring-1 ring-amber-500/20">วน</span>
<span class="text-[11px] font-bold text-slate-500 uppercase tracking-tighter">นน 10:30 .</span>
</div>
<h3 class="text-xl font-black text-white mb-4 tracking-tight">นดอนรบสคอรสเรยน</h3>
<p class="text-slate-400 leading-relaxed font-medium">ขอใหสนกกบการเรยนร หากมอสงสยสามารถสอบถามผสอนไดตลอดเวลาคร</p>
</div>
<div class="text-center text-slate-500 py-10">งไมประกาศในขณะน</div>
</div>
<!-- Tab Content: Lesson Details (Grid System) -->
@ -323,42 +314,64 @@ onBeforeUnmount(() => {
<!-- Left Side: Video + Title + Notes (8/12) -->
<div class="md:col-span-8 space-y-10 pb-24 md:pb-0">
<!-- Video Unit -->
<!-- Content Display Area -->
<div class="aspect-video relative group overflow-hidden rounded-2xl ring-1 ring-white/10 shadow-2xl bg-black">
<video
ref="videoRef"
class="w-full h-full object-contain"
@timeupdate="updateProgress"
@loadedmetadata="onVideoMetadataLoaded"
@ended="onVideoEnded"
@play="isPlaying = true"
@pause="isPlaying = false"
:src="getLocalizedText(currentLesson.content)"
>
<!-- Fallback message -->
Browser ของคณไมรองรบการเลนวโอ
</video>
<!-- Play Overlay -->
<div v-if="!isPlaying" class="absolute inset-0 flex items-center justify-center bg-black/5 backdrop-blur-[1px]">
<button class="w-16 h-16 md:w-20 md:h-20 rounded-full bg-blue-600/30 border border-white/20 flex items-center justify-center backdrop-blur-xl hover:scale-110 transition-transform shadow-lg" @click="togglePlay">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-white fill-white translate-x-0.5" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<!-- Controls Overlay -->
<div class="absolute bottom-0 inset-x-0 p-4 md:p-6 pt-16 bg-gradient-to-t from-black/95 via-black/40 to-transparent">
<div class="flex items-center gap-4 text-[11px] font-bold text-white/80">
<button class="text-xs hover:text-blue-500 transition-colors" @click="togglePlay">{{ isPlaying ? '' : '' }}</button>
<span class="font-mono">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
<div class="flex-grow h-[3px] bg-white/10 rounded-full relative cursor-pointer" @click="seek">
<div :style="{ width: videoProgress + '%' }" class="absolute top-0 left-0 h-full bg-blue-500 rounded-full"/>
</div>
<div class="flex gap-4">
<span class="cursor-pointer hover:text-white" @click="videoRef?.requestFullscreen()"></span>
</div>
<!-- Case: Quiz -->
<div v-if="currentLesson.type === 'QUIZ'" class="absolute inset-0 flex flex-col items-center justify-center bg-slate-900 text-white p-6 text-center">
<div class="w-20 h-20 rounded-full bg-blue-600/20 flex items-center justify-center mb-6">
<span class="text-4xl">📝</span>
</div>
<h3 class="text-xl font-bold mb-2">แบบทดสอบ: {{ getLocalizedText(currentLesson.title) }}</h3>
<p class="text-slate-400 mb-6 text-sm max-w-md">ทดสอบความเขาใจของคณในบทเรยนน</p>
<NuxtLink :to="`/classroom/quiz?course_id=${courseId}&lesson_id=${currentLesson.id}`" class="px-8 py-3 bg-blue-600 hover:bg-blue-500 rounded-xl font-bold text-sm transition-colors">
เรมทำแบบทดสอบ
</NuxtLink>
</div>
<!-- Case: Video with Source -->
<template v-else-if="videoSrc">
<video
ref="videoRef"
class="w-full h-full object-contain"
@timeupdate="updateProgress"
@loadedmetadata="onVideoMetadataLoaded"
@ended="onVideoEnded"
@play="isPlaying = true"
@pause="isPlaying = false"
:src="videoSrc"
>
Browser ของคณไมรองรบการเลนวโอ
</video>
<!-- Play Overlay -->
<div v-if="!isPlaying" class="absolute inset-0 flex items-center justify-center bg-black/5 backdrop-blur-[1px]">
<button class="w-16 h-16 md:w-20 md:h-20 rounded-full bg-blue-600/30 border border-white/20 flex items-center justify-center backdrop-blur-xl hover:scale-110 transition-transform shadow-lg" @click="togglePlay">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-white fill-white translate-x-0.5" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<!-- Controls Overlay -->
<div class="absolute bottom-0 inset-x-0 p-4 md:p-6 pt-16 bg-gradient-to-t from-black/95 via-black/40 to-transparent">
<div class="flex items-center gap-4 text-[11px] font-bold text-white/80">
<button class="text-xs hover:text-blue-500 transition-colors" @click="togglePlay">{{ isPlaying ? '' : '' }}</button>
<span class="font-mono">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
<div class="flex-grow h-[3px] bg-white/10 rounded-full relative cursor-pointer" @click="seek">
<div :style="{ width: videoProgress + '%' }" class="absolute top-0 left-0 h-full bg-blue-500 rounded-full"/>
</div>
<div class="flex gap-4">
<span class="cursor-pointer hover:text-white" @click="videoRef?.requestFullscreen()"></span>
</div>
</div>
</div>
</template>
<!-- Case: Video but No Source -->
<div v-else class="absolute inset-0 flex flex-col items-center justify-center bg-slate-900 text-slate-500">
<span class="text-4xl mb-4">🎬</span>
<p class="font-medium text-sm">ไมพบวโอในบทเรยนน</p>
</div>
</div>
<!-- Lesson Title -->

View file

@ -50,13 +50,18 @@ const submitQuiz = (auto = false) => {
}
}
const route = useRoute()
const confirmExit = () => {
const courseId = route.query.course_id
const target = courseId ? `/classroom/learning?course_id=${courseId}` : '/classroom/learning'
if (currentScreen.value === 'taking') {
if (confirm('คุณกำลังทำแบบทดสอบอยู่ หากออกตอนนี้ความคืบหน้าจะหายไป ยืนยันที่จะออก?')) {
router.push('/classroom/learning')
router.push(target)
}
} else {
router.push('/classroom/learning')
router.push(target)
}
}

View file

@ -65,7 +65,7 @@ const loadEnrolledCourses = async () => {
// Wait, CourseCard likely needs course ID to link to /classroom/learning/:id
enrollment_id: item.id,
title: getLocalizedText(item.course.title),
progress: item.progress_percentage,
progress: 0, // item.progress_percentage (Forced to 0 as requested)
completed: item.status === 'COMPLETED',
thumbnail_url: item.course.thumbnail_url // CourseCard might need this
}))
@ -134,6 +134,7 @@ const downloadCertificate = () => {
<!-- In Progress Course Card -->
<CourseCard
v-if="!course.completed"
:id="course.id"
:title="course.title"
:progress="course.progress"
:image="course.thumbnail_url"
@ -142,6 +143,7 @@ const downloadCertificate = () => {
<!-- Completed Course Card -->
<CourseCard
v-else
:id="course.id"
:title="course.title"
:progress="100"
:image="course.thumbnail_url"
@ -173,7 +175,7 @@ const downloadCertificate = () => {
<h2 class="font-bold mb-2">{{ $t('enrollment.successTitle') }}</h2>
<p class="text-muted mb-6">{{ $t('enrollment.successDesc') }}</p>
<div class="flex flex-col gap-2">
<NuxtLink to="/classroom/learning" class="btn btn-primary w-full">{{ $t('enrollment.startNow') }}</NuxtLink>
<NuxtLink :to="`/classroom/learning?course_id=${route.query.course_id}`" class="btn btn-primary w-full">{{ $t('enrollment.startNow') }}</NuxtLink>
<button class="btn btn-secondary w-full" @click="showEnrollModal = false">{{ $t('enrollment.later') }}</button>
</div>
</div>

View file

@ -99,7 +99,7 @@
- เพิ่มระบบ Filter หมวดหมู่ (ย่อ/ขยายได้) และ Search Real-time
- ปรับปรุงการ์ดแสดงผลคอร์สให้รองรับข้อมูลจริงจาก Backend
### ✅ Phase 3: Full Learning Experience (Current)
### ✅ Phase 3: Full Learning Experience & Refinement (Current)
- **Classroom Integration (`learning.vue`):**
- เปลี่ยนจาก Mock Data เป็น **Real API Data**
@ -109,7 +109,22 @@
- 💾 **Progress:** บันทึกเวลาเรียนอัตโนมัติทุก 10 วินาที
- ✅ **Auto-Complete:** จบบทเรียนอัตโนมัติเมื่อดูวิดีโอจบ
- **API Expansion:** เพิ่ม Endpoints ครบวงจรใน `useCourse.ts` (`fetchLesson`, `saveProgress`, `enroll`)
- **System Stability & Features:**
- 🔄 **Category Filtering:** ใช้งานได้จริง เลือก Filter ได้หลายหมวดหมู่พร้อมกัน
- 🛠️ **Error Handling:** จัดการ Case ลงทะเบียนซ้ำ (409 Conflict) แจ้งเตือนภาษาไทยถูกต้อง
---
## 🔍 5. Status & Missing Integrations (ส่วนที่ต้องพัฒนาต่อ)
จากการตรวจสอบล่าสุด รายการดังต่อไปนี้ยังไม่ถูกเชื่อมต่อกับ Backend (Mockup/Partial):
1. **Quiz System 🔴 (สำคัญมาก):** หน้า `classroom/quiz.vue` ยังเป็น Mockup ทั้งหมด ขาด API:
- `GET /api/quizzes/:id` (ดึงโจทย์)
- `POST /api/quizzes/:id/submit` (ส่งคำตอบ)
2. **Certificates:** ยังไม่มีหน้าดาวน์โหลดใบประกาศ
3. **Reviews & Ratings:** การแสดงดาวและคอมเมนต์ยังเป็น Static Data
4. **File Upload:** ระบบแก้ไขโปรไฟล์ยังไม่รองรับการอัปโหลดรูปภาพจริง
---
> _เอกสารนี้อัปเดตล่าสุด: 2026-01-20_