feat: Implement 'My Courses' dashboard, including course cards, classroom learning, and quiz pages.
This commit is contained in:
parent
fc3e2820cc
commit
f6bbd60f2b
5 changed files with 111 additions and 74 deletions
|
|
@ -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')">
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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_
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue