feat: Implement the core online learning classroom interface with video player, quiz management, and announcements.
This commit is contained in:
parent
11f9ad57cd
commit
21273fcaeb
2 changed files with 72 additions and 33 deletions
|
|
@ -195,9 +195,9 @@ const resetAndNavigate = (path: string) => {
|
||||||
// 2. Clear all localStorage
|
// 2. Clear all localStorage
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
|
||||||
// 3. Restore whitelisted keys
|
// 3. Restore ONLY whitelisted keys
|
||||||
Object.entries(whitelist).forEach(([key, value]) => {
|
Object.keys(whitelist).forEach(key => {
|
||||||
localStorage.setItem(key, value)
|
localStorage.setItem(key, whitelist[key])
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. Force hard reload to the new path
|
// 4. Force hard reload to the new path
|
||||||
|
|
@ -278,6 +278,12 @@ const loadLesson = async (lessonId: number) => {
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
videoProgress.value = 0
|
videoProgress.value = 0
|
||||||
currentTime.value = 0
|
currentTime.value = 0
|
||||||
|
initialSeekTime.value = 0
|
||||||
|
maxWatchedTime.value = 0
|
||||||
|
lastSavedTime.value = -1
|
||||||
|
lastSavedTimestamp.value = 0
|
||||||
|
lastLocalSaveTimestamp.value = 0
|
||||||
|
currentDuration.value = 0
|
||||||
currentLesson.value = null // This will unmount VideoPlayer and hide content
|
currentLesson.value = null // This will unmount VideoPlayer and hide content
|
||||||
|
|
||||||
isLessonLoading.value = true
|
isLessonLoading.value = true
|
||||||
|
|
@ -332,27 +338,36 @@ const loadLesson = async (lessonId: number) => {
|
||||||
|
|
||||||
// 2. Fetch Initial Progress (Resume Playback)
|
// 2. Fetch Initial Progress (Resume Playback)
|
||||||
if (currentLesson.value.type === 'VIDEO') {
|
if (currentLesson.value.type === 'VIDEO') {
|
||||||
// A. Server Progress
|
// If already completed, clear local resume point to allow fresh re-watch
|
||||||
const progressRes = await fetchVideoProgress(lessonId)
|
const isCompleted = currentLesson.value.progress?.is_completed || false
|
||||||
let serverProgress = 0
|
|
||||||
if (progressRes.success && progressRes.data?.video_progress_seconds) {
|
|
||||||
serverProgress = progressRes.data.video_progress_seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// B. Local Progress (Buffer)
|
|
||||||
const localProgress = getLocalProgress(lessonId)
|
|
||||||
|
|
||||||
// C. Hybrid Resume (Max Wins)
|
if (isCompleted) {
|
||||||
const resumeTime = Math.max(serverProgress, localProgress)
|
const key = getLocalProgressKey(lessonId)
|
||||||
|
if (key && typeof window !== 'undefined') {
|
||||||
if (resumeTime > 0) {
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
initialSeekTime.value = resumeTime
|
|
||||||
maxWatchedTime.value = resumeTime
|
|
||||||
currentTime.value = resumeTime
|
|
||||||
} else {
|
|
||||||
initialSeekTime.value = 0
|
initialSeekTime.value = 0
|
||||||
maxWatchedTime.value = 0
|
maxWatchedTime.value = 0
|
||||||
|
currentTime.value = 0
|
||||||
|
} else {
|
||||||
|
// Not completed? Resume from where we left off
|
||||||
|
const progressRes = await fetchVideoProgress(lessonId)
|
||||||
|
let serverProgress = 0
|
||||||
|
if (progressRes.success && progressRes.data?.video_progress_seconds) {
|
||||||
|
serverProgress = progressRes.data.video_progress_seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
const localProgress = getLocalProgress(lessonId)
|
||||||
|
const resumeTime = Math.max(serverProgress, localProgress)
|
||||||
|
|
||||||
|
if (resumeTime > 0) {
|
||||||
|
initialSeekTime.value = resumeTime
|
||||||
|
maxWatchedTime.value = resumeTime
|
||||||
|
currentTime.value = resumeTime
|
||||||
|
} else {
|
||||||
|
initialSeekTime.value = 0
|
||||||
|
maxWatchedTime.value = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -556,13 +571,19 @@ const videoSrc = computed(() => {
|
||||||
|
|
||||||
// เมื่อวิดีโอจบ ให้บันทึกว่าเรียนจบ (Complete)
|
// เมื่อวิดีโอจบ ให้บันทึกว่าเรียนจบ (Complete)
|
||||||
const onVideoEnded = async () => {
|
const onVideoEnded = async () => {
|
||||||
// Safety check before saving
|
|
||||||
const lesson = currentLesson.value
|
const lesson = currentLesson.value
|
||||||
if (!lesson || !lesson.progress || lesson.progress.is_completed || isCompleting.value) return
|
if (!lesson) return
|
||||||
|
|
||||||
|
// Clear local storage on end since it's completed
|
||||||
|
const key = getLocalProgressKey(lesson.id)
|
||||||
|
if (key && typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lesson.progress?.is_completed || isCompleting.value) return
|
||||||
|
|
||||||
isCompleting.value = true
|
isCompleting.value = true
|
||||||
try {
|
try {
|
||||||
// Force save progress at 100% to trigger backend completion
|
|
||||||
await performSaveProgress(true, false)
|
await performSaveProgress(true, false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save progress on end:', err)
|
console.error('Failed to save progress on end:', err)
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@
|
||||||
|
|
||||||
### 1.1 Tech Stack
|
### 1.1 Tech Stack
|
||||||
|
|
||||||
- **Core:** [Nuxt 3](https://nuxt.com) (Vue 3 Composition API), TypeScript `^5.0`
|
- **Core:** [Nuxt 3](https://nuxt.com) (v`^3.11.2`), TypeScript `^5.4.5`
|
||||||
- **UI Framework:** Quasar Framework (via `nuxt-quasar-ui`)
|
- **UI Framework:** Quasar Framework `^2.15.2` (via `nuxt-quasar-ui ^3.0.0`)
|
||||||
- **Styling:** Tailwind CSS (Utility) + Vanilla CSS Variables (Theming/Dark Mode)
|
- **Styling:** Tailwind CSS `^6.12.0` (Utility) + Vanilla CSS Variables (Theming/Dark Mode)
|
||||||
- **State Management:** `ref`/`reactive` (Local) + `useState` (Global/Shared State)
|
- **State Management:** `ref`/`reactive` (Local) + `useState` (Global/Shared State)
|
||||||
- **Localization:** `@nuxtjs/i18n` (Supports JSON locales in `i18n/locales/`)
|
- **Localization:** `@nuxtjs/i18n` (Supports JSON locales in `i18n/locales/`)
|
||||||
- **Media Control:** `useMediaPrefs` (Command Pattern for global volume/mute state)
|
- **Media Control:** `useMediaPrefs` (Command Pattern for global volume/mute state)
|
||||||
|
|
@ -57,17 +57,20 @@
|
||||||
- **Common (`components/common/`):**
|
- **Common (`components/common/`):**
|
||||||
- `GlobalLoader.vue`: Loading indicator ทั่วทั้งแอป
|
- `GlobalLoader.vue`: Loading indicator ทั่วทั้งแอป
|
||||||
- `LanguageSwitcher.vue`: ปุ่มเปลี่ยนภาษา (TH/EN)
|
- `LanguageSwitcher.vue`: ปุ่มเปลี่ยนภาษา (TH/EN)
|
||||||
- `AppHeader.vue`, `MobileNav.vue`: Navigation หลัก
|
- `AppHeader.vue`, `MobileNav.vue`: Navigation หลัก (ใช้ร่วมกับ AppSidebar)
|
||||||
- `FormInput.vue`: Input field มาตรฐาน
|
- `FormInput.vue`: Input field มาตรฐาน
|
||||||
|
- **Layout (`components/layout/`):**
|
||||||
|
- `AppSidebar.vue`: Sidebar หลักสำหรับ Dashboard (Collapsible)
|
||||||
|
- `LandingHeader.vue`: Header เฉพาะสำหรับหน้า Landing Page
|
||||||
- **Course (`components/course/`):**
|
- **Course (`components/course/`):**
|
||||||
- `CourseCard.vue`: การ์ดแสดงผลคอร์ส (ใช้ซ้ำหลายหน้า)
|
- `CourseCard.vue`: การ์ดแสดงผลคอร์ส รองรับ Progress และ **Glassmorphism** ในโหมดมืด
|
||||||
- **Discovery (`components/discovery/`):**
|
- **Discovery (`components/discovery/`):**
|
||||||
- `CategorySidebar.vue`: Sidebar ตัวกรองหมวดหมู่แบบย่อ/ขยายได้
|
- `CategorySidebar.vue`: Sidebar ตัวกรองหมวดหมู่แบบย่อ/ขยายได้
|
||||||
- `CourseDetailView.vue`: หน้ารายละเอียดคอร์สขนาดใหญ่ (Video Preview + Syllabus)
|
- `CourseDetailView.vue`: หน้ารายละเอียดคอร์สขนาดใหญ่ (Video Preview + Syllabus)
|
||||||
- **Classroom (`components/classroom/`):**
|
- **Classroom (`components/classroom/`):**
|
||||||
- `CurriculumSidebar.vue`: Sidebar บทเรียนและสถานะการเรียน
|
- `CurriculumSidebar.vue`: Sidebar บทเรียนและสถานะการเรียน
|
||||||
- `AnnouncementModal.vue`: Modal แสดงประกาศของคอร์ส
|
- `AnnouncementModal.vue`: Modal แสดงประกาศของคอร์ส
|
||||||
- `VideoPlayer.vue`: Video Player พร้อม Custom Controls
|
- `VideoPlayer.vue`: Video Player พร้อม Custom Controls และ YouTube Support
|
||||||
- **User / Profile (`components/user/`, `components/profile/`):**
|
- **User / Profile (`components/user/`, `components/profile/`):**
|
||||||
- `UserAvatar.vue`: แสดงรูปโปรไฟล์ (รองรับ Fallback)
|
- `UserAvatar.vue`: แสดงรูปโปรไฟล์ (รองรับ Fallback)
|
||||||
- `ProfileEditForm.vue`: ฟอร์มแก้ไขข้อมูลส่วนตัว
|
- `ProfileEditForm.vue`: ฟอร์มแก้ไขข้อมูลส่วนตัว
|
||||||
|
|
@ -94,6 +97,7 @@
|
||||||
- **Classroom:**
|
- **Classroom:**
|
||||||
- `fetchCourseLearningInfo`: โครงสร้างบทเรียน (Chapters/Lessons)
|
- `fetchCourseLearningInfo`: โครงสร้างบทเรียน (Chapters/Lessons)
|
||||||
- `fetchLessonContent`: เนื้อหาวิดีโอ/Quiz/Attachments
|
- `fetchLessonContent`: เนื้อหาวิดีโอ/Quiz/Attachments
|
||||||
|
- `fetchCourseAnnouncements`: ดึงข้อมูลประกาศของคอร์ส
|
||||||
- `saveVideoProgress`: บันทึกเวลาเรียน (Sync Server)
|
- `saveVideoProgress`: บันทึกเวลาเรียน (Sync Server)
|
||||||
- **i18n Support:** `getLocalizedText` ตัวช่วยในการเลือกแสดงผลภาษา (TH/EN) ตาม Locale ปัจจุบันที่ผู้ใช้เลือก อัตโนมัติทั่วทั้งแอป
|
- **i18n Support:** `getLocalizedText` ตัวช่วยในการเลือกแสดงผลภาษา (TH/EN) ตาม Locale ปัจจุบันที่ผู้ใช้เลือก อัตโนมัติทั่วทั้งแอป
|
||||||
|
|
||||||
|
|
@ -164,6 +168,20 @@
|
||||||
- **Hardcoded Removal:** ทยอยลบข้อความภาษาไทยที่พิมพ์ค้างไว้ในโค้ด (เช่น ใน Sidebar หมวดหมู่) และแทนที่ด้วย i18n keys
|
- **Hardcoded Removal:** ทยอยลบข้อความภาษาไทยที่พิมพ์ค้างไว้ในโค้ด (เช่น ใน Sidebar หมวดหมู่) และแทนที่ด้วย i18n keys
|
||||||
- **Boot Sequence Fix:** แก้ไขปัญหาเว็บค้าง (Error 500) ที่เกิดจากการเรียกใช้ภาษาเร็วเกินไปก่อนที่ระบบจะพร้อม (`initialization error`)
|
- **Boot Sequence Fix:** แก้ไขปัญหาเว็บค้าง (Error 500) ที่เกิดจากการเรียกใช้ภาษาเร็วเกินไปก่อนที่ระบบจะพร้อม (`initialization error`)
|
||||||
|
|
||||||
7. **Landing Page & Header Refinement:**
|
7. **Classroom & UX Optimization (Mid-February 2026):**
|
||||||
- **Login Button:** อัปเกรดปุ่ม "เข้าสู่ระบบ" จากลิงก์ข้อความธรรมดา ให้เป็นปุ่มแบบ Secondary ที่โดดเด่นและชัดเจนขึ้น เพื่อดึงดูดผู้ใช้งาน
|
- **SPA Navigation for Learning:** เปลี่ยนระบบเลือกบทเรียนจากการ Reload หน้าเป็น SPA Navigation ทำให้เปลี่ยนวิดีโอ/บทเรียนได้ทันทีโดยไม่ต้อง Refresh หน้าจอ
|
||||||
- **Visual Hierarchy:** จัดลำดับความสำคัญของปุ่ม Get Started และ Login ให้สมดุลกันมากขึ้นในโหมดสว่างและโหมดมืด (Scrolled Header)
|
- **Announcement Persistence:** เพิ่มระบบเช็กสถานะการอ่านประกาศ (Unread Badge) โดยบันทึกสถานะล่าสุดลง LocalStorage แยกตามผู้ใช้และคอร์ส
|
||||||
|
- **YouTube Resume:** รองรับการเรียนต่อจากจุดเดิมสำหรับวิดีโอ YouTube (Time Seeking via URL parameter)
|
||||||
|
|
||||||
|
8. **Quiz System Enhancements:**
|
||||||
|
- **Answer Review Mode:** เพิ่มโหมดเฉลยข้อสอบหลังทำเสร็จ พร้อมการไฮไลท์สีที่ชัดเจน (เขียว = ถูก, แดง = ตอบผิด)
|
||||||
|
- **Shuffle Logic:** เพิ่มการสลับคำถามและตัวเลือก (Shuffle) เพื่อความโปร่งใสในการสอบ
|
||||||
|
- **Enhanced Feedback:** ปรับปรุง UI ผลลัพธ์การสอบให้มีความ Premium และเข้าใจง่ายขึ้น
|
||||||
|
|
||||||
|
9. **Security & Registration Polish:**
|
||||||
|
- **Phone Validation:** เพิ่มระบบตรวจสอบเบอร์โทรศัพท์ในหน้าสมัครสมาชิก (ต้องเป็นตัวเลขและยาวไม่เกิน 10 หลัก)
|
||||||
|
- **Enrollment Alert Logic:** ปรับปรุง Logic การสมัครเรียนให้ตรวจสอบสถานะ Enrollment เดิมก่อน เพื่อป้องกัน API Error และการเรียก request ซ้ำซ้อน
|
||||||
|
|
||||||
|
10. **Profile & Certificates:**
|
||||||
|
- **Verification Badge:** เพิ่มการแสดงผลสถานะการยืนยันอีเมลในหน้าโปรไฟล์ พร้อมปุ่มส่งอีเมลยืนยันหากยังไม่ได้ทำ
|
||||||
|
- **Certificate Flow:** ปรับปรุงระบบดาวน์โหลดใบประกาศนียบัตรให้รองรับทั้งการดึงไฟล์เดิมและสั่ง Generate ใหม่หากยังไม่มี
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue