2026-01-26 17:15:57 +07:00
< script setup lang = "ts" >
2026-01-13 10:46:40 +07:00
/ * *
* @ file learning . vue
2026-01-23 09:47:32 +07:00
* @ description หน ้ าเร ี ยนออนไลน ์ ( Classroom Interface )
* จ ั ดการแสดงผลว ิ ด ี โอรายการบทเร ี ยน และต ิ ดตามความค ื บหน ้ า
* ออกแบบให ้ เหม ื อนระบบ LMS มาตรฐาน
2026-01-13 10:46:40 +07:00
* /
definePageMeta ( {
2026-02-12 16:05:37 +07:00
layout : false ,
2026-01-13 10:46:40 +07:00
middleware : 'auth'
} )
useHead ( {
title : 'ห้องเรียนออนไลน์ - e-Learning'
} )
2026-01-20 15:51:58 +07:00
const route = useRoute ( )
2026-01-29 15:07:45 +07:00
const router = useRouter ( )
2026-01-26 17:15:57 +07:00
const { t } = useI18n ( )
2026-01-29 14:02:32 +07:00
const { user } = useAuth ( )
2026-02-09 11:40:41 +07:00
const { fetchCourseLearningInfo , fetchLessonContent , saveVideoProgress , checkLessonAccess , fetchVideoProgress , fetchCourseAnnouncements , markLessonComplete , getLocalizedText } = useCourse ( )
2026-02-12 16:05:37 +07:00
const $q = useQuasar ( )
2026-01-20 15:51:58 +07:00
2026-02-27 10:05:33 +07:00
// การจัดการสถานะ (State management)
2026-01-13 10:46:40 +07:00
const sidebarOpen = ref ( false )
2026-01-20 15:51:58 +07:00
const courseId = computed ( ( ) => Number ( route . query . course _id ) )
2026-01-23 09:47:32 +07:00
// ==========================================
// 1. ตัวแปร State (สถานะของ UI)
// ==========================================
// courseData: เก็บข้อมูลโครงสร้างคอร์ส (บทเรียนต่างๆ)
2026-01-20 15:51:58 +07:00
const courseData = ref < any > ( null )
2026-02-27 10:05:33 +07:00
const announcements = ref < any [ ] > ( [ ] ) // สถานะของประกาศ (Announcements state)
const showAnnouncementsModal = ref ( false ) // สถานะของป๊อปอัป (Modal state)
const hasUnreadAnnouncements = ref ( false ) // ติดตามสถานะที่ยังไม่ได้อ่าน (Unread state tracking)
2026-02-02 14:37:26 +07:00
2026-02-27 10:05:33 +07:00
// ฟังก์ชันช่วยเหลือสำหรับเก็บสถานะการอ่านถาวร (Helper for persistent read status)
2026-02-02 14:37:26 +07:00
const getAnnouncementStorageKey = ( ) => {
if ( ! user . value ? . id || ! courseId . value ) return ''
return ` read_announcements: ${ user . value . id } : ${ courseId . value } `
}
const checkUnreadAnnouncements = ( ) => {
if ( ! announcements . value || announcements . value . length === 0 ) {
hasUnreadAnnouncements . value = false
return
}
if ( typeof window === 'undefined' ) return
const key = getAnnouncementStorageKey ( )
if ( ! key ) return
const lastRead = localStorage . getItem ( key )
if ( ! lastRead ) {
hasUnreadAnnouncements . value = true
return
}
const lastReadDate = new Date ( lastRead ) . getTime ( )
const hasNew = announcements . value . some ( a => {
const annDate = new Date ( a . created _at || Date . now ( ) ) . getTime ( )
2026-02-27 10:05:33 +07:00
// ตรวจสอบว่าประกาศใหม่กว่าที่อ่านครั้งล่าสุดหรือไม่ (Check if announcement is strictly newer than last read)
2026-02-02 14:37:26 +07:00
return annDate > lastReadDate
} )
hasUnreadAnnouncements . value = hasNew
}
2026-02-27 10:05:33 +07:00
// ฟังก์ชันจัดการเมื่อเปิดประกาศ (Handler for opening announcements)
2026-02-02 14:37:26 +07:00
const handleOpenAnnouncements = ( ) => {
showAnnouncementsModal . value = true
2026-02-27 10:05:33 +07:00
hasUnreadAnnouncements . value = false // ลบป้ายแจ้งเตือนเมื่อคลิก (Clear unread badge on click)
2026-02-02 14:37:26 +07:00
const key = getAnnouncementStorageKey ( )
if ( key ) {
localStorage . setItem ( key , new Date ( ) . toISOString ( ) )
}
}
2026-01-23 09:47:32 +07:00
// currentLesson: บทเรียนที่กำลังเรียนอยู่ปัจจุบัน
2026-01-20 15:51:58 +07:00
const currentLesson = ref < any > ( null )
2026-01-23 09:47:32 +07:00
const isLoading = ref ( true ) // โหลดข้อมูลคอร์ส
const isLessonLoading = ref ( false ) // โหลดเนื้อหาบทเรียน
2026-01-20 15:51:58 +07:00
2026-01-23 09:47:32 +07:00
// Video Player State (สถานะวิดีโอ)
2026-01-20 15:51:58 +07:00
const videoRef = ref < HTMLVideoElement | null > ( null )
const isPlaying = ref ( false )
const videoProgress = ref ( 0 )
const currentTime = ref ( 0 )
const duration = ref ( 0 )
2026-02-20 14:58:18 +07:00
const activeTab = ref ( 'content' )
2026-02-02 15:34:40 +07:00
2026-01-20 15:51:58 +07:00
2026-01-13 10:46:40 +07:00
const toggleSidebar = ( ) => {
sidebarOpen . value = ! sidebarOpen . value
}
2026-02-27 10:05:33 +07:00
// การจัดการตรรกะจำนวนครั้งในการทำแบบทดสอบ (Logic Quiz Attempt Management)
2026-02-12 16:05:37 +07:00
const quizStatus = computed ( ( ) => {
if ( ! currentLesson . value || currentLesson . value . type !== 'QUIZ' || ! currentLesson . value . quiz ) return null
const quiz = currentLesson . value . quiz
const latestAttempt = quiz . latest _attempt
const allowMultiple = quiz . allow _multiple _attempts
2026-02-27 10:05:33 +07:00
// หากยังไม่เคยทดสอบ (If never attempted)
2026-02-12 16:05:37 +07:00
if ( ! latestAttempt ) {
return {
canStart : true ,
label : t ( 'quiz.startBtn' ) ,
icon : 'play_arrow' ,
showScore : false
}
}
2026-02-27 10:05:33 +07:00
// หากอนุญาตให้ทดสอบได้หลายครั้ง (If multiple attempts allowed)
2026-02-12 16:05:37 +07:00
if ( allowMultiple ) {
return {
canStart : true ,
label : t ( 'quiz.retryBtn' ) ,
icon : 'refresh' ,
showScore : true ,
score : latestAttempt . score ,
isPassed : latestAttempt . is _passed
}
}
2026-02-27 10:05:33 +07:00
// ไม่อนุญาตให้สอบได้หลายครั้ง (allowMultiple is false (Single attempt only))
// ล็อกแบบทดสอบหลังทำเสร็จไม่ว่าจะผ่านหรือไม่ผ่าน (Lock the quiz regardless of pass/fail once attempted)
2026-02-12 16:05:37 +07:00
return {
canStart : false ,
label : latestAttempt . is _passed ? t ( 'quiz.passedStatus' ) : t ( 'quiz.failedStatus' ) ,
icon : latestAttempt . is _passed ? 'check_circle' : 'cancel' ,
showScore : true ,
score : latestAttempt . score ,
isPassed : latestAttempt . is _passed
}
} )
const handleStartQuiz = ( ) => {
if ( ! currentLesson . value || ! currentLesson . value . quiz ) return
const quiz = currentLesson . value . quiz
2026-02-27 10:05:33 +07:00
// หากทำได้ครั้งเดียวและนี่เป็นครั้งแรก (If multiple attempts are disabled and it's the first time)
2026-02-12 16:05:37 +07:00
if ( ! quiz . allow _multiple _attempts && ! quiz . latest _attempt ) {
$q . dialog ( {
title : ` <div class="text-slate-900 dark:text-white font-black text-xl"> ${ t ( 'quiz.warningTitle' ) } </div> ` ,
message : ` <div class="text-slate-600 dark:text-slate-300 text-base leading-relaxed mt-2"> ${ t ( 'quiz.singleAttemptWarning' ) } </div> ` ,
html : true ,
persistent : true ,
class : 'rounded-[24px]' ,
ok : {
label : t ( 'quiz.continue' ) ,
color : 'primary' ,
unelevated : true ,
rounded : true ,
class : 'px-8 font-black'
} ,
cancel : {
label : t ( 'common.cancel' ) ,
color : 'grey-7' ,
flat : true ,
rounded : true ,
class : 'font-bold'
}
} ) . onOk ( ( ) => {
router . push ( ` /classroom/quiz?course_id= ${ courseId . value } &lesson_id= ${ currentLesson . value . id } ` )
} )
} else {
router . push ( ` /classroom/quiz?course_id= ${ courseId . value } &lesson_id= ${ currentLesson . value . id } ` )
}
}
2026-02-09 15:55:36 +07:00
// Helper สำหรับรีเซ็ตข้อมูลและย้ายหน้า (Hard Reload)
const resetAndNavigate = ( path : string ) => {
if ( import . meta . client ) {
// 1. เก็บข้อมูลที่ต้องการรักษาไว้ (Whitelist)
const whitelist : Record < string , string > = { }
const keepKeys = [ 'remembered_email' , 'theme' , 'auth_token' ] // เพิ่ม auth_token เพื่อไม่ให้หลุด login
for ( let i = 0 ; i < localStorage . length ; i ++ ) {
const key = localStorage . key ( i )
if ( ! key ) continue
// เก็บเฉพาะ key ที่อยู่ใน whitelist หรือเป็นข้อมูลประกาศ/แจ้งเตือน
if ( keepKeys . includes ( key ) || key . startsWith ( 'read_announcements:' ) ) {
const value = localStorage . getItem ( key )
if ( value !== null ) whitelist [ key ] = value
}
}
2026-02-27 10:05:33 +07:00
// 2. ลบข้อมูลใน localStorage ทั้งหมด (Clear all localStorage)
2026-02-09 15:55:36 +07:00
localStorage . clear ( )
2026-02-27 10:05:33 +07:00
// 3. คืนค่าเฉพาะคีย์ที่อนุญาต (Restore ONLY whitelisted keys)
2026-02-13 11:42:10 +07:00
Object . keys ( whitelist ) . forEach ( key => {
localStorage . setItem ( key , whitelist [ key ] )
2026-02-09 15:55:36 +07:00
} )
2026-02-27 10:05:33 +07:00
// 4. บังคับโหลดหน้าเว็บใหม่ไปยังเส้นทางใหม่ (Force hard reload to the new path)
2026-02-09 15:55:36 +07:00
window . location . href = path
} else {
2026-02-27 10:05:33 +07:00
// การทำงานสำรองสำหรับ SSR (SSR Fallback)
2026-02-09 15:55:36 +07:00
router . push ( path )
}
}
2026-02-10 11:21:29 +07:00
// Logic loadLesson ให้ลื่นไหลแบบ SPA
2026-01-23 10:55:26 +07:00
const handleLessonSelect = ( lessonId : number ) => {
2026-02-09 15:55:36 +07:00
if ( currentLesson . value ? . id === lessonId ) return
2026-02-10 11:21:29 +07:00
2026-02-27 10:05:33 +07:00
// 1. อัปเดตพารามิเตอร์ใน URL (Update URL query params)
2026-02-10 11:21:29 +07:00
router . push ( { query : { ... route . query , lesson _id : lessonId . toString ( ) } } )
2026-02-27 10:05:33 +07:00
// 2. โหลดเนื้อหาโดยไม่ต้องรีเฟรชหน้าเว็บ (Load content without refresh)
2026-02-10 11:21:29 +07:00
loadLesson ( lessonId )
2026-02-27 10:05:33 +07:00
// ปิดแถบด้านข้างบนมือถือ (Close sidebar on mobile)
2026-02-10 11:21:29 +07:00
if ( sidebarOpen . value ) {
sidebarOpen . value = false
}
2026-02-09 15:55:36 +07:00
}
2026-02-10 11:21:29 +07:00
// Logic สำหรับการกดย้อนกลับหรือออกจากหน้าเรียน (ใช้ Hard Reload ตามเดิม)
2026-02-09 15:55:36 +07:00
const handleExit = ( path : string ) => {
resetAndNavigate ( path )
2026-01-13 10:46:40 +07:00
}
2026-01-20 15:51:58 +07:00
// Data Fetching
2026-01-23 09:47:32 +07:00
// ==========================================
// 2. ฟังก์ชันโหลดข้อมูล (Data Fetching)
// ==========================================
// โหลดโครงสร้างคอร์สและบทเรียนทั้งหมด
2026-01-20 15:51:58 +07:00
const loadCourseData = async ( ) => {
if ( ! courseId . value ) return
isLoading . value = true
2026-02-05 09:56:10 +07:00
2026-01-20 15:51:58 +07:00
try {
const res = await fetchCourseLearningInfo ( courseId . value )
if ( res . success ) {
courseData . value = res . data
2026-02-27 10:05:33 +07:00
// ตรรกะการโหลดอัตโนมัติ: เช็ค URL ก่อน หากไม่มีให้โหลดบทเรียนแรก (Auto-load logic: Check URL first, fallback to first available lesson)
2026-02-10 11:21:29 +07:00
const urlLessonId = route . query . lesson _id ? Number ( route . query . lesson _id ) : null
if ( urlLessonId ) {
loadLesson ( urlLessonId )
} else if ( ! currentLesson . value ) {
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 )
}
2026-01-20 15:51:58 +07:00
}
2026-02-02 14:37:26 +07:00
2026-02-27 10:05:33 +07:00
// ดึงข้อมูลประกาศของคอร์สเรียน (Fetch Course Announcements)
2026-02-02 14:37:26 +07:00
const annRes = await fetchCourseAnnouncements ( courseId . value )
if ( annRes . success ) {
announcements . value = annRes . data || [ ]
checkUnreadAnnouncements ( )
}
2026-01-20 15:51:58 +07:00
}
} catch ( error ) {
2026-02-12 16:05:37 +07:00
console . error ( 'Error loading course data:' , error )
2026-01-20 15:51:58 +07:00
} finally {
isLoading . value = false
2026-01-13 10:46:40 +07:00
}
2026-01-20 15:51:58 +07:00
}
2026-01-13 10:46:40 +07:00
2026-01-20 15:51:58 +07:00
const loadLesson = async ( lessonId : number ) => {
if ( currentLesson . value ? . id === lessonId ) return
2026-02-27 10:05:33 +07:00
// ล้างสถานะวิดีโอเดิมเพื่อบังคับตั้งค่าใหม่ (Clear previous video state & unload component to force reset)
2026-01-20 15:51:58 +07:00
isPlaying . value = false
videoProgress . value = 0
currentTime . value = 0
2026-02-13 11:42:10 +07:00
initialSeekTime . value = 0
maxWatchedTime . value = 0
lastSavedTime . value = - 1
lastSavedTimestamp . value = 0
lastLocalSaveTimestamp . value = 0
currentDuration . value = 0
2026-02-27 10:05:33 +07:00
currentLesson . value = null // ตัวนี้จะทำการซ่อนวิดีโอและซ่อนเนื้อหา (This will unmount VideoPlayer and hide content)
2026-02-05 09:56:10 +07:00
2026-01-20 15:51:58 +07:00
isLessonLoading . value = true
try {
2026-02-27 10:05:33 +07:00
// ป้องกันไว้ก่อน: ตรวจสอบสิทธิ์การเข้าถึง (Optional: Check access first)
2026-01-20 15:51:58 +07:00
const accessRes = await checkLessonAccess ( courseId . value , lessonId )
if ( accessRes . success && ! accessRes . data . is _accessible ) {
2026-01-29 11:09:29 +07:00
let msg = t ( 'classroom.notAvailable' )
2026-02-27 10:05:33 +07:00
// จัดการเหตุผลล็อกเฉพาะจุด (Handle specific lock reasons)
2026-01-29 11:09:29 +07:00
if ( accessRes . data . lock _reason ) {
msg = accessRes . data . lock _reason
} else if ( accessRes . data . required _quiz _pass && ! accessRes . data . required _quiz _pass . is _passed ) {
const quizTitle = getLocalizedText ( accessRes . data . required _quiz _pass . title )
2026-02-12 12:01:37 +07:00
msg = t ( 'classroom.quizRequired' , { title : quizTitle } )
2026-01-29 11:09:29 +07:00
} else if ( accessRes . data . required _lessons && accessRes . data . required _lessons . length > 0 ) {
const reqLesson = accessRes . data . required _lessons . find ( ( l : any ) => ! l . is _completed )
if ( reqLesson ) {
2026-02-12 12:01:37 +07:00
msg = t ( 'classroom.lessonRequired' , { title : getLocalizedText ( reqLesson . title ) } )
2026-01-29 11:09:29 +07:00
}
} else if ( accessRes . data . is _enrolled === false ) {
2026-02-12 12:01:37 +07:00
msg = t ( 'classroom.notEnrolled' )
2026-01-29 11:09:29 +07:00
}
alert ( msg )
2026-01-20 15:51:58 +07:00
isLessonLoading . value = false
return
}
2026-02-27 10:05:33 +07:00
// 1. ดึงข้อมูลเนื้อหา (Fetch content)
2026-01-20 15:51:58 +07:00
const res = await fetchLessonContent ( courseId . value , lessonId )
if ( res . success ) {
currentLesson . value = res . data
2026-02-27 10:05:33 +07:00
// กำหนดค่าออบเจ็กต์ความคืบหน้าหากไม่มี (สำคัญสำหรับผู้ใช้ใหม่) (Initialize progress object if missing)
2026-02-04 16:22:42 +07:00
if ( ! currentLesson . value . progress ) {
currentLesson . value . progress = { }
}
2026-02-27 10:05:33 +07:00
// อัปเดตสถานะสำเร็จของบทเรียนบน UI อย่างปลอดภัย (Update Lesson Completion UI status safely)
2026-01-29 13:17:58 +07:00
if ( currentLesson . value ? . progress ? . is _completed && courseData . value ) {
for ( const chapter of courseData . value . chapters ) {
const lesson = chapter . lessons . find ( ( l : any ) => l . id === lessonId )
if ( lesson ) {
if ( ! lesson . progress ) lesson . progress = { }
lesson . progress . is _completed = true
2026-02-27 10:05:33 +07:00
lesson . is _completed = true // ตั้งค่าสถานะสำเร็จให้เป็นมาตรฐาน (Standardize completion property)
2026-01-29 13:17:58 +07:00
break
}
}
}
2026-02-27 10:05:33 +07:00
// 2. ดึงความคืบหน้าเริ่มต้นเพื่อเล่นต่อ (Fetch Initial Progress (Resume Playback))
2026-01-29 13:17:58 +07:00
if ( currentLesson . value . type === 'VIDEO' ) {
2026-02-27 10:05:33 +07:00
// หากเรียนจบแล้ว ให้ล้างจุดที่ดูล่าสุดเพื่อดูใหม่ได้ (If already completed, clear local resume point to allow fresh re-watch)
2026-02-13 11:42:10 +07:00
const isCompleted = currentLesson . value . progress ? . is _completed || false
2026-01-29 14:02:32 +07:00
2026-02-13 11:42:10 +07:00
if ( isCompleted ) {
const key = getLocalProgressKey ( lessonId )
if ( key && typeof window !== 'undefined' ) {
localStorage . removeItem ( key )
}
2026-01-29 13:17:58 +07:00
initialSeekTime . value = 0
2026-01-29 14:02:32 +07:00
maxWatchedTime . value = 0
2026-02-13 11:42:10 +07:00
currentTime . value = 0
} else {
2026-02-27 10:05:33 +07:00
// ถ้ายังเรียนไม่จบ? กลับไปเล่นจากจุดเดิม (Not completed? Resume from where we left off)
2026-02-13 11:42:10 +07:00
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
}
2026-01-20 15:51:58 +07:00
}
}
}
} catch ( error ) {
console . error ( 'Error loading lesson:' , error )
} finally {
isLessonLoading . value = false
}
}
2026-02-27 10:05:33 +07:00
// ข้อมูลอ้างอิงวิดีโอ (Video Player Ref (Component))
2026-02-02 17:13:58 +07:00
const videoPlayerComp = ref ( null )
2026-02-27 10:05:33 +07:00
// สถานะความก้าวหน้าและวิดีโอ (Video & Progress State)
2026-01-29 13:17:58 +07:00
const initialSeekTime = ref ( 0 )
2026-02-27 10:05:33 +07:00
const maxWatchedTime = ref ( 0 ) // การติดตามแบบไม่ย้อนกลับ (Anti-rewind monotonic tracking)
2026-01-29 13:17:58 +07:00
const lastSavedTime = ref ( - 1 )
2026-02-27 10:05:33 +07:00
const lastSavedTimestamp = ref ( 0 ) // เวลาบันทึกในเซิร์ฟเวอร์ (Server throttle timestamp)
const lastLocalSaveTimestamp = ref ( 0 ) // เวลาบันทึกในเครื่อง (Local throttle timestamp)
const currentDuration = ref ( 0 ) // ติดตามเวลาของวิดีโอ (Track duration for save logic)
2026-01-29 14:02:32 +07:00
2026-02-27 10:05:33 +07:00
// ฟังก์ชันช่วยเหลือ: ชื่อคีย์สำหรับ Local Storage (Helper: Get Local Storage Key)
2026-01-29 14:02:32 +07:00
const getLocalProgressKey = ( lessonId : number ) => {
if ( ! user . value ? . id ) return null
return ` progress: ${ user . value . id } : ${ lessonId } `
}
2026-02-27 10:05:33 +07:00
// ฟังก์ชันช่วยเหลือ: ดึงความคืบหน้าจากเครื่อง (Helper: Get Local Progress)
2026-01-29 14:02:32 +07:00
const getLocalProgress = ( lessonId : number ) : number => {
try {
const key = getLocalProgressKey ( lessonId )
if ( ! key ) return 0
const stored = localStorage . getItem ( key )
return stored ? parseFloat ( stored ) : 0
} catch ( e ) {
return 0
}
}
2026-02-27 10:05:33 +07:00
// ฟังก์ชันช่วยเหลือ: บันทึกลงเครื่อง (Helper: Save to Local Storage)
2026-01-29 14:02:32 +07:00
const saveLocalProgress = ( lessonId : number , time : number ) => {
try {
const key = getLocalProgressKey ( lessonId )
if ( key ) {
localStorage . setItem ( key , time . toString ( ) )
}
} catch ( e ) {
2026-02-27 10:05:33 +07:00
// ข้ามกรณีเกิดข้อผิดพลาดในการบันทึก (Ignore storage errors)
2026-01-29 14:02:32 +07:00
}
}
2026-01-29 13:17:58 +07:00
2026-02-27 10:05:33 +07:00
// ฟังก์ชันจัดการเวลาของวิดีโอ (จาก Component) (Handler: Video Time Update (from Component))
2026-02-02 17:13:58 +07:00
const handleVideoTimeUpdate = ( cTime : number , dur : number ) => {
currentDuration . value = dur || 0
2026-02-27 10:05:33 +07:00
// อัปเดตความคืบหน้าแบบทางเดียว (Update Monotonic Progress)
2026-02-02 17:13:58 +07:00
if ( cTime > maxWatchedTime . value ) {
maxWatchedTime . value = cTime
}
2026-02-27 10:05:33 +07:00
// ตรรกะ: บันทึกเป็นระยะ (Logic: Periodic Save)
2026-02-02 17:13:58 +07:00
if ( currentLesson . value ? . id ) {
const now = Date . now ( )
2026-02-27 10:05:33 +07:00
// 1. การหน่วงเวลาบันทึกในเครื่อง (5 วินาที) (Local Save Throttle (5 seconds))
2026-02-02 17:13:58 +07:00
if ( now - lastLocalSaveTimestamp . value > 5000 ) {
saveLocalProgress ( currentLesson . value . id , maxWatchedTime . value )
lastLocalSaveTimestamp . value = now
}
2026-02-27 10:05:33 +07:00
// 2. การหน่วงเวลาบันทึกบนเซิร์ฟเวอร์ (จัดการใน performSaveProgress)
// หมายเหตุ: เราไม่เช็ค isPlaying ตรงนี้เพราะถ้าเวลาเดินแปลว่าเล่นอยู่ (Note: We don't check isPlaying here because if time is updating, it IS playing.)
2026-02-02 17:13:58 +07:00
performSaveProgress ( false , false )
}
2026-01-20 15:51:58 +07:00
}
2026-01-13 10:46:40 +07:00
2026-02-04 16:22:42 +07:00
const onVideoMetadataLoaded = ( duration : number ) => {
if ( duration > 0 ) {
currentDuration . value = duration
}
2026-01-13 10:46:40 +07:00
}
2026-02-27 10:05:33 +07:00
const isCompleting = ref ( false ) // ตัวแปรกันการแย่งกันทำงานตอนเรียนจบ (Flag to prevent race conditions during completion)
2026-02-02 14:37:26 +07:00
2026-01-29 13:17:58 +07:00
// -----------------------------------------------------
2026-02-27 10:05:33 +07:00
// ระบบบันทึกความคืบหน้าที่แข็งแกร่ง (ไฮบริด: ในเครื่อง + เซิร์ฟเวอร์) (ROBUST PROGRESS SAVING SYSTEM (Hybrid: Local + Server))
2026-01-29 13:17:58 +07:00
// -----------------------------------------------------
2026-02-27 10:05:33 +07:00
// ฟังก์ชันหลักสำหรับบันทึกลงเซิร์ฟเวอร์ (Main Server Save Function)
2026-01-29 13:17:58 +07:00
const performSaveProgress = async ( force : boolean = false , keepalive : boolean = false ) => {
2026-02-02 14:37:26 +07:00
const lesson = currentLesson . value
2026-02-02 17:13:58 +07:00
if ( ! lesson || lesson . type !== 'VIDEO' ) return
2026-02-04 16:22:42 +07:00
2026-02-27 10:05:33 +07:00
// ทำให้แน่ใจว่ามีออบเจ็กต์ความคืบหน้าอยู่ (Ensure progress object exists)
2026-02-04 16:22:42 +07:00
if ( ! lesson . progress ) lesson . progress = { }
2026-02-02 14:37:26 +07:00
2026-02-27 10:05:33 +07:00
// 1. ป้องกันเมื่อดูจบแล้ว: ไม่ต้องบันทึกอีกถ้าดูจบไปแล้ว (Completed Guard: Stop everything if already completed)
2026-02-02 14:37:26 +07:00
if ( lesson . progress . is _completed ) return
2026-02-27 10:05:33 +07:00
// 2. ป้องกันการเรียกซ้ำ: ไม่ทำงานถ้ากำลังบันทึกเวลาจบอยู่ (Race Condition Guard: Stop if currently completing)
2026-02-02 14:37:26 +07:00
if ( isCompleting . value ) return
2026-01-29 13:17:58 +07:00
const now = Date . now ( )
2026-02-27 10:05:33 +07:00
const maxSec = Math . floor ( maxWatchedTime . value ) // ใช้เวลาที่ดูไปมากที่สุด (Use max watched time)
2026-02-02 17:13:58 +07:00
const durationSec = Math . floor ( currentDuration . value || 0 )
2026-01-29 13:17:58 +07:00
2026-02-27 10:05:33 +07:00
// 3. ตรวจสอบการเดินหน้า: ยอมให้บันทึก 0 ได้ถ้าเป็นการบันทึกครั้งแรก (lastSavedTime is -1) (Monotonic Check: Allow saving 0 if it's the very first save)
2026-02-04 16:22:42 +07:00
if ( ! force ) {
if ( lastSavedTime . value === - 1 ) {
2026-02-27 10:05:33 +07:00
// บันทึกครั้งแรก: ยอมรับ 0 หรือมากกว่า (First time save: allow 0 or more)
2026-02-04 16:22:42 +07:00
if ( maxSec < 0 ) return
} else if ( maxSec <= lastSavedTime . value ) {
2026-02-27 10:05:33 +07:00
// การบันทึกครั้งถัดไป: ต้องมากกว่าครั้งล่าสุด (Subsequent saves: must be greater than last saved)
2026-02-04 16:22:42 +07:00
return
}
}
2026-01-29 13:17:58 +07:00
2026-02-27 10:05:33 +07:00
// 4. ตรวจสอบการหน่วงเวลา: เซิร์ฟเวอร์ (15 วินาที) (Throttle Check: Server Throttle (15 seconds))
2026-01-29 14:02:32 +07:00
if ( ! force && ( now - lastSavedTimestamp . value < 15000 ) ) return
2026-01-29 13:17:58 +07:00
2026-02-27 10:05:33 +07:00
// เตรียมบันทึก (Prepare for Save)
2026-01-29 14:02:32 +07:00
lastSavedTime . value = maxSec
2026-01-29 13:17:58 +07:00
lastSavedTimestamp . value = now
2026-02-27 10:05:33 +07:00
// เช็คว่าการบันทึกนี้จะทำให้จบบทเรียนหรือไม่ (เช่น 100% หรือบังคับจบ) (Check if this save might complete the lesson)
2026-02-04 16:22:42 +07:00
const isFinishing = force || ( durationSec > 0 && maxSec >= durationSec )
2026-01-29 13:17:58 +07:00
2026-02-02 14:37:26 +07:00
if ( isFinishing ) {
isCompleting . value = true
}
try {
const res = await saveVideoProgress ( lesson . id , maxSec , durationSec , keepalive )
2026-02-27 10:05:33 +07:00
// จัดการเมื่อดูจบ (กลยุทธ์เฉพาะฝั่งหน้าเว็บ: ให้ผ่านที่ 95%) (Handle Completion (Frontend-only strategy: 95% threshold))
// เพื่อให้เครื่องหมายถูกขึ้นที่ 95% ตรงกับหลังบ้าน (This ensures the checkmark appears at 95% to match backend.)
2026-02-06 12:57:29 +07:00
const progressPercentage = durationSec > 0 ? ( maxSec / durationSec ) : 0
const isCompletedNow = res . success && ( res . data ? . is _completed || progressPercentage >= 0.95 )
if ( isCompletedNow ) {
2026-02-10 11:08:10 +07:00
const wasAlreadyCompleted = lesson . progress ? . is _completed
2026-02-02 14:37:26 +07:00
markLessonAsCompletedLocally ( lesson . id )
if ( lesson . progress ) lesson . progress . is _completed = true
2026-02-10 11:08:10 +07:00
2026-02-27 10:05:33 +07:00
// หากเพิ่งเรียนจบใหม่ ให้โหลดเนื้อหาใหม่เพื่อปลดล็อกบทถัดไป (If newly completed, reload course data to unlock next lesson in sidebar)
2026-02-10 11:08:10 +07:00
if ( ! wasAlreadyCompleted ) {
await loadCourseData ( )
}
2026-02-02 14:37:26 +07:00
}
} catch ( err ) {
console . error ( 'Save progress failed' , err )
} finally {
if ( isFinishing ) {
isCompleting . value = false
}
2026-01-29 13:17:58 +07:00
}
}
2026-02-27 10:05:33 +07:00
// ฟังก์ชันช่วยเหลือสำหรับอัปเดตแถบด้านข้าง (Helper to update Sidebar UI)
2026-01-29 13:17:58 +07:00
const markLessonAsCompletedLocally = ( lessonId : number ) => {
if ( courseData . value ) {
for ( const chapter of courseData . value . chapters ) {
const lesson = chapter . lessons . find ( ( l : any ) => l . id === lessonId )
if ( lesson ) {
2026-02-27 10:05:33 +07:00
// สอดคล้องกับโครงสร้าง API (Compatible with API structure)
2026-01-29 16:26:30 +07:00
lesson . is _completed = true
2026-02-02 14:37:26 +07:00
if ( ! lesson . progress ) lesson . progress = { }
lesson . progress . is _completed = true
2026-01-29 13:17:58 +07:00
break
}
}
}
}
2026-01-21 17:03:09 +07:00
const videoSrc = computed ( ( ) => {
if ( ! currentLesson . value ) return ''
2026-02-04 16:22:42 +07:00
let url = ''
2026-01-29 11:09:29 +07:00
2026-02-27 10:05:33 +07:00
// ใช่ video_url โดยตรงจาก API ก่อน (Use explicit video_url from API first)
2026-02-04 16:22:42 +07:00
if ( currentLesson . value . video _url ) {
url = currentLesson . value . video _url
} else {
2026-02-27 10:05:33 +07:00
// สำรอง (ตรรกะแบบเก่า)
2026-02-04 16:22:42 +07:00
const content = getLocalizedText ( currentLesson . value . content )
if ( content && ( content . startsWith ( 'http' ) || content . startsWith ( '/' ) ) && ! content . includes ( ' ' ) ) {
url = content
}
2026-01-21 17:03:09 +07:00
}
2026-02-04 16:22:42 +07:00
if ( ! url ) return ''
2026-02-27 10:05:33 +07:00
// รองรับการเล่นต่อสำหรับ YouTube (Support Resume for YouTube)
2026-02-04 16:22:42 +07:00
const isYoutube = url . toLowerCase ( ) . includes ( 'youtube.com' ) || url . toLowerCase ( ) . includes ( 'youtu.be' )
if ( isYoutube && initialSeekTime . value > 0 ) {
const separator = url . includes ( '?' ) ? '&' : '?'
return ` ${ url } ${ separator } t= ${ Math . floor ( initialSeekTime . value ) } `
}
return url
2026-01-21 17:03:09 +07:00
} )
2026-01-29 11:09:29 +07:00
// เมื่อวิดีโอจบ ให้บันทึกว่าเรียนจบ (Complete)
2026-01-20 15:51:58 +07:00
const onVideoEnded = async ( ) => {
2026-02-02 14:37:26 +07:00
const lesson = currentLesson . value
2026-02-13 11:42:10 +07:00
if ( ! lesson ) return
2026-02-27 10:05:33 +07:00
// ล้าง localStorage เนื่องจากดจบแล้ว (Clear local storage on end since it's completed)
2026-02-13 11:42:10 +07:00
const key = getLocalProgressKey ( lesson . id )
if ( key && typeof window !== 'undefined' ) {
localStorage . removeItem ( key )
}
if ( lesson . progress ? . is _completed || isCompleting . value ) return
2026-01-29 17:17:40 +07:00
2026-02-04 16:22:42 +07:00
isCompleting . value = true
try {
await performSaveProgress ( true , false )
} catch ( err ) {
console . error ( 'Failed to save progress on end:' , err )
} finally {
isCompleting . value = false
2026-01-29 17:52:52 +07:00
}
2026-01-13 10:46:40 +07:00
}
2026-01-20 15:51:58 +07:00
onMounted ( ( ) => {
loadCourseData ( )
} )
onBeforeUnmount ( ( ) => {
2026-02-27 10:05:33 +07:00
// ล้างสถานะเมื่อออกจากหน้าเพื่อให้หน้าเหมือนใหม่ตอนกลับมา (Clear state when leaving the page to ensure fresh start on return)
2026-02-05 09:56:10 +07:00
courseData . value = null
currentLesson . value = null
2026-01-20 15:51:58 +07:00
} )
2026-01-13 10:46:40 +07:00
< / script >
< template >
2026-02-20 14:58:18 +07:00
< q-layout view = "hHh lpR lFf" class = "bg-[var(--bg-body)] text-[var(--text-main)]" >
2026-01-26 09:27:31 +07:00
<!-- Header -- >
2026-02-20 14:58:18 +07:00
< q-header bordered class = "bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-16" >
< q-toolbar class = "h-full px-4" >
<!-- 1. Left Side : Back & Title -- >
< div class = "flex items-center gap-4 flex-grow overflow-hidden" >
<!-- Back Button -- >
< q-btn
flat
round
dense
color = "primary"
class = "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
@ click = "handleExit('/dashboard/my-courses')"
>
< q-icon name = "arrow_back" size = "20px" / >
< q-tooltip > { { $t ( 'classroom.backToDashboard' ) } } < / q-tooltip >
< / q-btn >
<!-- Course Title -- >
< div class = "flex flex-col" >
< h1 class = "text-base md:text-lg font-bold text-slate-900 dark:text-white truncate max-w-[200px] md:max-w-md leading-tight" >
{ { courseData ? getLocalizedText ( courseData . course . title ) : $t ( 'classroom.loadingTitle' ) } }
< / h1 >
< / div >
< / div >
2026-01-26 09:27:31 +07:00
2026-02-20 14:58:18 +07:00
<!-- 2. Right Side : Actions -- >
< div class = "flex items-center gap-3" >
<!-- Sidebar Toggle ( Right Side ) -- >
< q-btn
flat
round
dense
class = "text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
@ click = "toggleSidebar"
>
< q-icon name = "menu_open" size = "24px" class = "transform rotate-180" / >
< q-tooltip > { { $t ( 'classroom.curriculum' ) } } < / q-tooltip >
< / q-btn >
<!-- Announcements Button ( Refined ) -- >
2026-02-09 10:37:42 +07:00
< q-btn
flat
round
dense
2026-02-20 14:58:18 +07:00
class = "bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-slate-700 transition-all relative overflow-visible"
2026-02-09 10:37:42 +07:00
@ click = "handleOpenAnnouncements"
>
2026-02-20 14:58:18 +07:00
< q-icon name = "campaign" size = "22px" / >
<!-- Red Dot Notification -- >
< span v-if = "hasUnreadAnnouncements" class="absolute top-2 right-2 w-2.5 h-2.5 bg-rose-500 border-2 border-white dark:border-slate-900 rounded-full" > < / span >
2026-02-12 16:05:37 +07:00
< q-tooltip > { { $t ( 'classroom.announcements' ) } } < / q-tooltip >
2026-02-09 10:37:42 +07:00
< / q-btn >
2026-01-26 09:27:31 +07:00
< / div >
< / q-toolbar >
< / q-header >
2026-02-27 10:05:33 +07:00
<!-- แถบด ้ านข ้ าง ( บทเร ี ยน ) - วางช ิ ดขวาผ ่ านพร ็ อพพ ์ -- >
2026-02-02 17:13:58 +07:00
< CurriculumSidebar
2026-01-26 09:27:31 +07:00
v - model = "sidebarOpen"
2026-02-02 17:13:58 +07:00
: courseData = "courseData"
: currentLessonId = "currentLesson?.id"
: isLoading = "isLoading"
: hasUnreadAnnouncements = "hasUnreadAnnouncements"
@ select - lesson = "handleLessonSelect"
@ open - announcements = "handleOpenAnnouncements"
/ >
2026-01-26 09:27:31 +07:00
2026-02-27 10:05:33 +07:00
<!-- พ ื ้ นท ี ่ เน ื ้ อหาหล ั ก ( Main Content ) -- >
2026-01-26 09:27:31 +07:00
< q-page-container class = "bg-white dark:bg-slate-900" >
< q-page class = "flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]" >
2026-02-27 10:05:33 +07:00
<!-- กรอบว ิ ด ี โอและพ ื ้ นท ี ่ เน ื ้ อหา ( Video Player & Content Area ) -- >
2026-02-20 14:58:18 +07:00
< div class = "w-full h-full p-4 md:p-6 flex-grow overflow-y-auto" >
2026-02-27 10:05:33 +07:00
<!-- 1. สถานะกำล ั งโหลด ( โครงสร ้ างเสม ื อน ( Skeleton ) สมบ ู รณ ์ แบบ ) ( LOADING STATE ( Comprehensive Skeleton ) ) -- >
2026-02-12 16:05:37 +07:00
< div v-if = "isLessonLoading" class="animate-fade-in" >
2026-02-27 10:05:33 +07:00
<!-- โครงภาพว ิ ด ี โอ ( Video Skeleton ) -- >
2026-02-12 16:05:37 +07:00
< div class = "aspect-video bg-slate-200 dark:bg-slate-800 rounded-3xl animate-pulse flex items-center justify-center mb-10 overflow-hidden relative shadow-xl focus:outline-none" >
< img
v - if = "courseData?.course?.thumbnail_url"
: src = "courseData.course.thumbnail_url"
class = "absolute inset-0 w-full h-full object-cover opacity-20 blur-md"
/ >
< div class = "absolute inset-0 bg-gradient-to-br from-slate-200/50 to-slate-300/50 dark:from-slate-900/80 dark:to-slate-800/80" > < / div >
< div class = "z-10 flex flex-col items-center" >
< q-spinner size = "3.5rem" color = "primary" :thickness = "2" / >
< p class = "mt-4 text-slate-500 font-bold text-xs uppercase tracking-[0.2em]" > { { $t ( 'common.loading' ) } } < / p >
2026-02-12 12:01:37 +07:00
< / div >
2026-02-12 16:05:37 +07:00
< / div >
2026-02-27 10:05:33 +07:00
<!-- โครงข ้ อม ู ล ( Info Skeleton ) -- >
2026-02-12 16:05:37 +07:00
< div class = "bg-white dark:bg-slate-800/50 p-8 rounded-3xl border border-slate-100 dark:border-white/5 shadow-sm" >
< div class = "h-10 bg-slate-200 dark:bg-slate-800 rounded-xl w-3/4 mb-4 animate-pulse" > < / div >
< div class = "h-4 bg-slate-100 dark:bg-slate-800 rounded-lg w-full mb-2 animate-pulse" > < / div >
< div class = "h-4 bg-slate-100 dark:bg-slate-800 rounded-lg w-2/3 animate-pulse" > < / div >
2026-02-05 09:56:10 +07:00
< / div >
< / div >
2026-02-27 10:05:33 +07:00
<!-- 2. สถานะพร ้ อมใช ้ งาน ( ข ้ อม ู ลบทเร ี ยนจร ิ ง ) ( READY STATE ( Real Lesson Content ) ) -- >
2026-02-12 16:05:37 +07:00
< div v -else -if = " currentLesson " class = "animate-fade-in" >
2026-02-27 10:05:33 +07:00
<!-- ส ่ วนการเล ่ นว ิ ด ี โอ ( Video Player ) -- >
2026-02-12 16:05:37 +07:00
< VideoPlayer
v - if = "videoSrc"
ref = "videoPlayerComp"
: src = "videoSrc"
: poster = "courseData?.course?.thumbnail_url"
: initialSeekTime = "initialSeekTime"
@ timeupdate = "handleVideoTimeUpdate"
@ ended = "onVideoEnded"
@ loadedmetadata = "(d: number) => onVideoMetadataLoaded(d)"
/ >
2026-02-27 10:05:33 +07:00
<!-- ข ้ อม ู ลบทเร ี ยน ( Lesson Info ) -- >
2026-02-12 16:05:37 +07:00
< div class = "bg-[var(--bg-surface)] p-6 md:p-8 rounded-3xl shadow-sm border border-[var(--border-color)]" >
2026-01-27 14:02:07 +07:00
<!-- ใช ้ ส ี จากต ั วแปรกลาง : จะแยกโหมดให ้ อ ั ตโนม ั ต ิ ( สว ่ าง = ดำ / ม ื ด = ขาว ) -- >
2026-02-02 14:37:26 +07:00
< div class = "flex items-start justify-between gap-4 mb-4" >
2026-02-12 12:01:37 +07:00
< h1 class = "text-3xl md:text-5xl font-black text-slate-900 dark:text-white leading-tight tracking-tight font-display" > { { getLocalizedText ( currentLesson . title ) } } < / h1 >
2026-02-02 14:37:26 +07:00
< / div >
2026-01-27 14:02:07 +07:00
2026-02-02 14:37:26 +07:00
< 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 >
2026-01-26 17:15:57 +07:00
2026-02-27 10:05:33 +07:00
<!-- ช ่ องบทเร ี ยน ( Text / HTML ) ( Lesson Content Area ) -- >
2026-02-10 13:14:01 +07:00
< 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" >
2026-02-02 14:37:26 +07:00
< q-icon name = "quiz" size = "40px" / >
< / div >
2026-02-12 16:05:37 +07:00
< h2 class = "text-xl font-bold mb-2 text-slate-900 dark:text-white" > { { $t ( 'quiz.startTitle' ) } } < / h2 >
2026-02-02 14:37:26 +07:00
< p class = "text-slate-500 dark:text-slate-400 mb-6 max-w-md mx-auto" > { { getLocalizedText ( currentLesson . quiz ? . description || currentLesson . description ) } } < / p >
2026-01-29 13:17:58 +07:00
2026-02-10 13:14:01 +07:00
< div class = "flex justify-center flex-wrap gap-3 text-sm mb-8" >
2026-02-12 16:05:37 +07:00
< span v-if = "currentLesson.quiz?.questions?.length" class="px-4 py-1.5 bg-white dark:bg-slate-800 rounded-full border border-gray-100 dark:border-white/5 shadow-sm flex items-center gap-2 text-slate-700 dark:text-slate-300 font-bold" >
< q-icon name = "format_list_numbered" size = "14px" class = "text-blue-500" / > { { currentLesson . quiz . questions . length } } { { $t ( 'quiz.questions' ) } }
2026-02-10 13:14:01 +07:00
< / span >
2026-02-12 16:05:37 +07:00
< span v-if = "currentLesson.quiz?.time_limit" class="px-4 py-1.5 bg-white dark:bg-slate-800 rounded-full border border-gray-100 dark:border-white/5 shadow-sm flex items-center gap-2 text-slate-700 dark:text-slate-300 font-bold" >
< q-icon name = "schedule" size = "14px" class = "text-orange-500" / > { { currentLesson . quiz . time _limit } } { { $t ( 'quiz.minutes' ) } }
2026-02-10 13:14:01 +07:00
< / span >
2026-01-29 13:17:58 +07:00
< / div >
2026-02-12 16:05:37 +07:00
< div v-if = "quizStatus?.showScore" class="mb-8 p-6 bg-white dark:!bg-slate-800/80 rounded-[32px] border border-blue-50 dark:border-white/5 shadow-xl max-w-sm mx-auto backdrop-blur-md" >
< div class = "text-[10px] uppercase font-black tracking-[0.2em] text-slate-400 dark:text-slate-500 mb-4" > { { $t ( 'quiz.latestScore' ) } } < / div >
< div class = "flex items-center justify-center gap-6" >
< div class = "text-5xl font-black" : class = "quizStatus.isPassed ? 'text-emerald-500' : 'text-rose-500'" >
{ { quizStatus . score } }
< / div >
< div class = "h-12 w-px bg-slate-100 dark:bg-white/10" > < / div >
< div class = "flex flex-col items-start gap-1" >
< span class = "text-[10px] font-black px-2.5 py-1 rounded-lg uppercase tracking-wider" : class = "quizStatus.isPassed ? 'bg-emerald-500/10 text-emerald-500' : 'bg-rose-500/10 text-rose-500'" >
{ { quizStatus . isPassed ? $t ( 'quiz.passedStatus' ) : $t ( 'quiz.failedStatus' ) } }
< / span >
< span class = "text-[10px] text-slate-400 dark:text-slate-500 font-bold" > { { $t ( 'quiz.passingScore' ) } } { { currentLesson . quiz . passing _score } } % < / span >
< / div >
< / div >
< / div >
2026-01-29 13:17:58 +07:00
< q-btn
2026-02-12 16:05:37 +07:00
v - if = "quizStatus?.canStart"
2026-02-02 14:37:26 +07:00
class = "bg-blue-600 text-white shadow-lg shadow-blue-600/30 hover:shadow-blue-600/50 transition-all font-bold px-8"
2026-01-29 13:17:58 +07:00
size = "lg"
rounded
2026-02-02 14:37:26 +07:00
no - caps
2026-02-12 16:05:37 +07:00
: label = "quizStatus.label"
: icon = "quizStatus.icon"
@ click = "handleStartQuiz"
2026-01-29 13:17:58 +07:00
/ >
2026-02-12 16:05:37 +07:00
< div v -else -if = " quizStatus "
class = "inline-flex items-center gap-2 px-6 py-3 rounded-full font-bold"
: class = "quizStatus.isPassed ? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400'"
>
< q-icon :name = "quizStatus.icon" size = "20px" / >
{ { quizStatus . label } }
< / div >
2026-01-29 13:17:58 +07:00
< / div >
2026-02-02 14:37:26 +07:00
< div v -else -if = " currentLesson.content " class = "prose prose-lg dark:prose-invert max-w-none p-6 md:p-8 bg-gray-50 dark:bg-slate-800/50 rounded-2xl border border-gray-100 dark:border-white/5" >
< div v-html = "getLocalizedText(currentLesson.content)" class="leading-relaxed text-slate-800 dark:text-slate-200" > < / div >
2026-01-26 09:27:31 +07:00
< / div >
2026-01-29 11:09:29 +07:00
2026-02-27 10:05:33 +07:00
<!-- ส ่ วนเอกสารแนบ ( Attachments Section ) -- >
2026-02-02 14:37:26 +07:00
< div v-if = "currentLesson.attachments && currentLesson.attachments.length > 0" class="mt-8 pt-6 border-t border-gray-100 dark:border-white/5" >
2026-01-29 11:09:29 +07:00
< h3 class = "text-lg font-bold mb-4 text-slate-900 dark:text-white flex items-center gap-2" >
2026-02-02 14:37:26 +07:00
< div class = "w-8 h-8 rounded-lg bg-orange-100 dark:bg-orange-900/30 text-orange-600 flex items-center justify-center" >
< q-icon name = "attach_file" size = "18px" / >
< / div >
2026-02-12 12:01:37 +07:00
{ { $t ( 'classroom.attachments' ) } }
2026-01-29 11:09:29 +07:00
< / h3 >
< div class = "grid grid-cols-1 sm:grid-cols-2 gap-4" >
< a
v - for = "file in currentLesson.attachments"
: key = "file.file_name"
: href = "file.presigned_url"
target = "_blank"
2026-02-02 14:37:26 +07:00
class = "flex items-center gap-4 p-4 rounded-xl border border-slate-200 dark:!border-white/10 bg-white dark:!bg-slate-800 hover:border-blue-300 dark:hover:border-blue-700 hover:shadow-md transition-all group relative overflow-hidden"
2026-01-29 11:09:29 +07:00
>
2026-02-02 14:37:26 +07:00
< div class = "w-12 h-12 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-500 flex items-center justify-center flex-shrink-0" >
2026-01-29 11:09:29 +07:00
< q-icon name = "picture_as_pdf" size = "24px" / >
< / div >
2026-02-02 14:37:26 +07:00
< div class = "flex-1 min-w-0 z-10" >
< div class = "font-bold text-slate-900 dark:text-slate-200 truncate group-hover:text-blue-600 transition-colors text-sm md:text-base" >
2026-01-29 11:09:29 +07:00
{ { file . file _name } }
< / div >
2026-02-02 14:37:26 +07:00
< div class = "text-xs text-slate-500 dark:text-slate-400 mt-0.5" >
2026-01-29 11:09:29 +07:00
{ { ( file . file _size / 1024 / 1024 ) . toFixed ( 2 ) } } MB
< / div >
< / div >
2026-02-02 14:37:26 +07:00
< div class = "absolute inset-0 bg-blue-50/50 dark:bg-blue-900/10 opacity-0 group-hover:opacity-100 transition-opacity" > < / div >
< q-icon name = "download" class = "text-slate-300 group-hover:text-blue-500 z-10" / >
2026-01-29 11:09:29 +07:00
< / a >
< / div >
2026-02-12 16:05:37 +07:00
< / div > <!-- End Attachments -- >
< / div > <!-- End Lesson Info -- >
< / div > <!-- End Ready State Wrapper -- >
< / div > <!-- End Main Content Wrapper -- >
2026-01-26 09:27:31 +07:00
< / q-page >
< / q-page-container >
2026-01-13 10:46:40 +07:00
2026-02-02 14:37:26 +07:00
<!-- Announcements Modal -- >
2026-02-02 17:13:58 +07:00
< AnnouncementModal
v - model = "showAnnouncementsModal"
: announcements = "announcements"
/ >
2026-02-02 14:37:26 +07:00
2026-01-26 09:27:31 +07:00
< / q-layout >
2026-01-13 10:46:40 +07:00
< / template >
2026-01-26 09:27:31 +07:00
< style >
. mobile - hide - label . q - btn _ _content span {
display : none ;
2026-01-13 10:46:40 +07:00
}
2026-01-26 09:27:31 +07:00
@ media ( min - width : 768 px ) {
. mobile - hide - label . q - btn _ _content span {
display : inline ;
margin - left : 8 px ;
}
2026-01-13 10:46:40 +07:00
}
< / style >