feat: Implement e-learning classroom with video playback, progress tracking, and quiz functionality, alongside new course and category composables and Thai localization.
This commit is contained in:
parent
9232b6a21d
commit
4c575dc734
5 changed files with 570 additions and 169 deletions
|
|
@ -73,6 +73,24 @@ export const useCategory = () => {
|
|||
}
|
||||
} catch (err: any) {
|
||||
console.error('Fetch categories failed:', err)
|
||||
|
||||
// Retry logic for 429 Too Many Requests
|
||||
if (err.statusCode === 429 || err.status === 429) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5s
|
||||
try {
|
||||
const retryRes = await $fetch<CategoryResponse>(`${API_BASE_URL}/categories`, {
|
||||
method: 'GET',
|
||||
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {}
|
||||
})
|
||||
const cats = retryRes.data?.categories || []
|
||||
categoriesState.value = cats
|
||||
isLoaded.value = true
|
||||
return { success: true, data: cats, total: retryRes.data?.total || 0 }
|
||||
} catch (retryErr) {
|
||||
console.error('Retry fetch categories failed:', retryErr)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.data?.message || err.message || 'Error fetching categories'
|
||||
|
|
|
|||
|
|
@ -148,6 +148,24 @@ export const useCourse = () => {
|
|||
}
|
||||
} catch (err: any) {
|
||||
console.error('Fetch courses failed:', err)
|
||||
|
||||
// Retry logic for 429 Too Many Requests
|
||||
if (err.statusCode === 429 || err.status === 429) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5s
|
||||
try {
|
||||
const retryData = await $fetch<CourseResponse>(`${API_BASE_URL}/courses`, {
|
||||
method: 'GET',
|
||||
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {}
|
||||
})
|
||||
const courses = retryData.data || []
|
||||
coursesState.value = courses
|
||||
isCoursesLoaded.value = true
|
||||
return { success: true, data: courses, total: retryData.total || 0 }
|
||||
} catch (retryErr) {
|
||||
console.error('Retry fetch courses failed:', retryErr)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.data?.message || err.message || 'Error fetching courses'
|
||||
|
|
@ -215,21 +233,23 @@ export const useCourse = () => {
|
|||
} catch (err: any) {
|
||||
console.error('Enroll course failed:', err)
|
||||
|
||||
// เช็ค Error 409 Conflict (กรณีลงทะเบียนไปแล้ว)
|
||||
// เช็ค Error 409 Conflict หรือ 400 Bad Request (กรณีลงทะเบียนไปแล้ว)
|
||||
// API ใหม่ส่ง 400 พร้อม error.code = "VALIDATION_ERROR" และ message "Already enrolled..."
|
||||
const status = err.statusCode || err.status || err.response?.status
|
||||
const errorData = err.data?.error || err.data
|
||||
|
||||
if (status === 409) {
|
||||
if (status === 409 || (status === 400 && errorData?.message?.includes('Already enrolled'))) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'ท่านได้ลงทะเบียนไปแล้ว',
|
||||
code: 409
|
||||
code: 409 // Treat as conflict logic internally
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.data?.message || err.message || 'Error enrolling in course',
|
||||
code: err.data?.code
|
||||
error: errorData?.message || err.message || 'Error enrolling in course',
|
||||
code: errorData?.code || err.data?.code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -259,6 +279,26 @@ export const useCourse = () => {
|
|||
}
|
||||
} catch (err: any) {
|
||||
console.error('Fetch enrolled courses failed:', err)
|
||||
|
||||
// Retry for 429
|
||||
if (err.statusCode === 429 || err.status === 429) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params.page) queryParams.append('page', params.page.toString())
|
||||
if (params.limit) queryParams.append('limit', params.limit.toString())
|
||||
if (params.status && params.status !== 'ALL') queryParams.append('status', params.status)
|
||||
|
||||
const retryData = await $fetch<EnrolledCourseResponse>(`${API_BASE_URL}/students/courses?${queryParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {}
|
||||
})
|
||||
return { success: true, data: retryData.data || [], total: retryData.total || 0, page: retryData.page, limit: retryData.limit }
|
||||
} catch (retryErr) {
|
||||
console.error('Retry fetch enrolled courses failed:', retryErr)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.data?.message || err.message || 'Error fetching enrolled courses'
|
||||
|
|
@ -283,12 +323,27 @@ export const useCourse = () => {
|
|||
}
|
||||
} catch (err: any) {
|
||||
console.error('Fetch course learning info failed:', err)
|
||||
|
||||
// Retry for 429
|
||||
if (err.statusCode === 429 || err.status === 429) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
try {
|
||||
const retryData = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/courses/${courseId}/learn`, {
|
||||
method: 'GET',
|
||||
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {}
|
||||
})
|
||||
return { success: true, data: retryData.data }
|
||||
} catch (retryErr) {
|
||||
console.error('Retry fetch course learning info failed:', retryErr)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.data?.message || err.message || 'Error fetching course learning info',
|
||||
code: err.data?.code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ฟังก์ชันดึงเนื้อหาบทเรียน (Video, Content)
|
||||
|
|
@ -347,17 +402,25 @@ export const useCourse = () => {
|
|||
|
||||
// ฟังก์ชันบันทึกเวลาที่ดูวิดีโอ (Video Progress)
|
||||
// Endpoint: POST /students/lessons/:id/progress
|
||||
const saveVideoProgress = async (lessonId: number, progressSeconds: number, durationSeconds: number) => {
|
||||
const saveVideoProgress = async (lessonId: number, progressSeconds: number, durationSeconds: number, keepalive: boolean = false) => {
|
||||
try {
|
||||
const data = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/lessons/${lessonId}/progress`, {
|
||||
method: 'POST',
|
||||
headers: token.value ? {
|
||||
Authorization: `Bearer ${token.value}`
|
||||
// User Request: "Backend รับ header แบบ Authorization: <token> (ยังไม่ใช้ Bearer)"
|
||||
// Checking existing usage, most use Bearer. I will stick to existing Bearer for consistency unless it fails,
|
||||
// BUT user explicitly said "not using Bearer" in this specific prompt.
|
||||
// To be safe and minimal, I will keep using `Bearer ${token}` if that's what the codebase uses globally,
|
||||
// UNLESS I see strong evidence otherwise.
|
||||
// The user's curl example in Step 209 DOES NOT have "Bearer".
|
||||
// So I will remove it for these 2 functions as requested.
|
||||
Authorization: `${token.value}`
|
||||
} : {},
|
||||
body: {
|
||||
video_progress_seconds: progressSeconds,
|
||||
video_duration_seconds: durationSeconds
|
||||
}
|
||||
},
|
||||
keepalive: keepalive
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -159,25 +159,32 @@
|
|||
"attachments": "เอกสารประกอบ"
|
||||
},
|
||||
"quiz": {
|
||||
"exitTitle": "ออกจากแบบทดสอบ",
|
||||
"exitConfirm": "คุณกำลังทำแบบทดสอบอยู่ หากออกตอนนี้ความคืบหน้าจะหายไป ยืนยันที่จะออก?",
|
||||
"startTitle": "แบบทดสอบท้ายบท",
|
||||
"preparationTitle": "เตรียมความพร้อมก่อนเริ่มทำ",
|
||||
"instructionTitle": "คำชี้แจง",
|
||||
"instruction1": "ตั้งใจทำแบบทดสอบเพื่อวัดผลการเรียนรู้",
|
||||
"startTitle": "แบบทดสอบ",
|
||||
"questions": "ข้อ",
|
||||
"minutes": "นาที",
|
||||
"preparationTitle": "เตรียมความพร้อมก่อนเริ่ม",
|
||||
"instructionTitle": "คำแนะนำ",
|
||||
"instruction1": "แบบทดสอบนี้มีไว้เพื่อวัดความรู้ความเข้าใจของคุณในบทเรียนนี้",
|
||||
"startBtn": "เริ่มทำแบบทดสอบ",
|
||||
"nextBtn": "ข้อถัดไป",
|
||||
"prevBtn": "ข้อก่อนหน้า",
|
||||
"submitBtn": "ส่งคำตอบ",
|
||||
"submitConfirm": "คุณแน่ใจหรือไม่ว่าต้องการส่งคำตอบ?",
|
||||
"scoreTitle": "ผลการทดสอบ",
|
||||
"passMessage": "ยินดีด้วย! คุณสอบผ่านเกณฑ์",
|
||||
"failMessage": "คุณยังไม่ผ่านเกณฑ์ในครั้งนี้ ลองทบทวนบทเรียนและทำใหม่อีกครั้ง",
|
||||
"backToLesson": "กลับไปหน้าบทเรียน",
|
||||
"reviewAnswers": "ตรวจดูคำตอบ",
|
||||
"exitTitle": "ออกจากแบบทดสอบ",
|
||||
"timeLeft": "เวลาที่เหลือ",
|
||||
"noQuizData": "ขออภัย ไม่พบข้อมูลแบบทดสอบ",
|
||||
"noQuizDesc": "บทเรียนนี้อาจยังไม่มีแบบทดสอบ หรือกำลังอยู่ระหว่างการปรับปรุงเนื้อหา",
|
||||
"underDevelopment": "ระบบกำลังโหลดโจทย์คำถาม..."
|
||||
"submitConfirm": "คุณต้องการส่งคำตอบหรือไม่?",
|
||||
"exitConfirm": "คุณต้องการออกจากแบบทดสอบหรือไม่? การทำแบบทดสอบจะถูกยกเลิก",
|
||||
"submitValues": "ส่งคำตอบ",
|
||||
"question": "คำถาม",
|
||||
"backToLesson": "กลับไปหน้าเรียน",
|
||||
"submitting": "กำลังส่งคำตอบ...",
|
||||
"resultPassed": "ยินดีด้วย! คุณสอบผ่าน",
|
||||
"resultFailed": "เสียใจด้วย คุณสอบไม่ผ่าน",
|
||||
"passMessage": "คุณทำคะแนนได้ตามเกณฑ์ที่กำหนด",
|
||||
"failMessage": "คุณยังไม่ผ่านเกณฑ์ในครั้งนี้ ลองทบทวนบทเรียนและทำใหม่อีกครั้ง",
|
||||
"scoreLabel": "คะแนน",
|
||||
"correctLabel": "ตอบถูก",
|
||||
"retryBtn": "เริ่มทำแบบทดสอบใหม่",
|
||||
"scoreTitle": "ผลการทดสอบ",
|
||||
"noQuizData": "ไม่มีข้อมูลแบบทดสอบ",
|
||||
"noQuizDesc": "ไม่พบข้อมูลแบบทดสอบในขณะนี้",
|
||||
"pleaseSelectAnswer": "กรุณาเลือกคำตอบก่อนดำเนินการต่อ",
|
||||
"underDevelopment": "ส่วนนี้กำลังพัฒนา"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,34 +137,27 @@ const loadLesson = async (lessonId: number) => {
|
|||
if (res.success) {
|
||||
currentLesson.value = res.data
|
||||
|
||||
// 2. Fetch progress separately (New Flow)
|
||||
// Note: fetchLessonContent might return progress in some APIs, but here we use dedicated endpoint as requested
|
||||
const progressRes = await fetchVideoProgress(lessonId)
|
||||
|
||||
if (progressRes.success && progressRes.data) {
|
||||
const p = progressRes.data
|
||||
|
||||
// Restore video time
|
||||
if (p.video_progress_seconds > 0) {
|
||||
currentTime.value = p.video_progress_seconds
|
||||
// Force update video element if ready, otherwise onVideoMetadataLoaded will handle it
|
||||
if (videoRef.value) {
|
||||
videoRef.value.currentTime = currentTime.value
|
||||
}
|
||||
}
|
||||
|
||||
// Check if completed to update UI
|
||||
if (p.is_completed) {
|
||||
if (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
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update Lesson Completion UI status safely
|
||||
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
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Initial Progress (Resume Playback)
|
||||
if (currentLesson.value.type === 'VIDEO') {
|
||||
const progressRes = await fetchVideoProgress(lessonId)
|
||||
if (progressRes.success && progressRes.data?.video_progress_seconds) {
|
||||
console.log('Resuming at:', progressRes.data.video_progress_seconds)
|
||||
initialSeekTime.value = progressRes.data.video_progress_seconds
|
||||
currentTime.value = initialSeekTime.value // Update UI immediately
|
||||
} else {
|
||||
initialSeekTime.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -175,12 +168,23 @@ const loadLesson = async (lessonId: number) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Video & Progress State
|
||||
const initialSeekTime = ref(0)
|
||||
const lastSavedTime = ref(-1)
|
||||
const lastSavedTimestamp = ref(0)
|
||||
|
||||
const onVideoMetadataLoaded = () => {
|
||||
if (videoRef.value && currentLesson.value) {
|
||||
// Restore time if needed
|
||||
if (currentTime.value > 0) {
|
||||
videoRef.value.currentTime = currentTime.value
|
||||
if (videoRef.value) {
|
||||
// Resume playback if we have a saved position
|
||||
if (initialSeekTime.value > 0) {
|
||||
// Ensure we don't seek past duration
|
||||
const seekTo = Math.min(initialSeekTime.value, videoRef.value.duration || Infinity)
|
||||
videoRef.value.currentTime = seekTo
|
||||
}
|
||||
|
||||
// Sync volume settings
|
||||
videoRef.value.volume = volume.value
|
||||
videoRef.value.muted = isMuted.value
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,14 +195,110 @@ const togglePlay = () => {
|
|||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// ROBUST PROGRESS SAVING SYSTEM
|
||||
// -----------------------------------------------------
|
||||
|
||||
// Main Save Function
|
||||
const performSaveProgress = async (force: boolean = false, keepalive: boolean = false) => {
|
||||
if (!videoRef.value || !currentLesson.value || currentLesson.value.type !== 'VIDEO') return
|
||||
|
||||
const now = Date.now()
|
||||
const currentSec = Math.floor(videoRef.value.currentTime)
|
||||
const durationSec = Math.floor(videoRef.value.duration || 0)
|
||||
|
||||
// 1. Validation: Don't save if time hasn't changed (SPAM Protection)
|
||||
if (!force && currentSec === lastSavedTime.value) return
|
||||
|
||||
// 2. Validation: Throttle (Rate Limit) - e.g. every 10 seconds
|
||||
if (!force && (now - lastSavedTimestamp.value < 10000)) return
|
||||
|
||||
// Save
|
||||
lastSavedTime.value = currentSec
|
||||
lastSavedTimestamp.value = now
|
||||
|
||||
console.log(`Saving progress: ${currentSec}/${durationSec} (KeepAlive: ${keepalive})`)
|
||||
|
||||
const res = await saveVideoProgress(currentLesson.value.id, currentSec, durationSec, keepalive)
|
||||
|
||||
// Check completion from backend logic
|
||||
if (res.success && res.data?.is_completed) {
|
||||
markLessonAsCompletedLocally(currentLesson.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to update Sidebar UI
|
||||
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) {
|
||||
if (!lesson.progress) lesson.progress = {}
|
||||
lesson.progress.is_completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateProgress = () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
// UI Update
|
||||
currentTime.value = videoRef.value.currentTime
|
||||
duration.value = videoRef.value.duration
|
||||
videoProgress.value = (currentTime.value / duration.value) * 100
|
||||
|
||||
// Throttle save progress logic is handled in watcher or separate interval usually,
|
||||
// but let's check if we should save periodically here for simplicity or use specific events
|
||||
// Logic: Periodic Save (Throttle applied inside)
|
||||
if (isPlaying.value) {
|
||||
performSaveProgress(false, false)
|
||||
}
|
||||
}
|
||||
|
||||
// Volume Controls
|
||||
const volume = ref(1)
|
||||
const isMuted = ref(false)
|
||||
const previousVolume = ref(1)
|
||||
|
||||
const volumeIcon = computed(() => {
|
||||
if (isMuted.value || volume.value === 0) return 'volume_off'
|
||||
if (volume.value < 0.5) return 'volume_down'
|
||||
return 'volume_up'
|
||||
})
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!videoRef.value) return
|
||||
if (isMuted.value) {
|
||||
// Unmute
|
||||
isMuted.value = false
|
||||
volume.value = previousVolume.value || 1
|
||||
videoRef.value.muted = false
|
||||
videoRef.value.volume = volume.value
|
||||
} else {
|
||||
// Mute
|
||||
previousVolume.value = volume.value
|
||||
isMuted.value = true
|
||||
volume.value = 0
|
||||
videoRef.value.muted = true
|
||||
videoRef.value.volume = 0
|
||||
}
|
||||
}
|
||||
|
||||
const onVolumeChange = (val: any) => {
|
||||
// Handle both input event (event.target.value) or direct value if using q-slider
|
||||
const newVol = typeof val === 'number' ? val : Number(val.target.value)
|
||||
|
||||
if (!videoRef.value) return
|
||||
volume.value = newVol
|
||||
videoRef.value.volume = newVol
|
||||
|
||||
if (newVol > 0 && isMuted.value) {
|
||||
isMuted.value = false
|
||||
videoRef.value.muted = false
|
||||
} else if (newVol === 0 && !isMuted.value) {
|
||||
isMuted.value = true
|
||||
videoRef.value.muted = true
|
||||
}
|
||||
}
|
||||
|
||||
const videoSrc = computed(() => {
|
||||
|
|
@ -219,40 +319,42 @@ const videoSrc = computed(() => {
|
|||
// ==========================================
|
||||
// บันทึกอัตโนมัติทุกๆ 10 วินาทีเมื่อเล่นวิดีโอ
|
||||
// บันทึกอัตโนมัติทุกๆ 10 วินาทีเมื่อเล่นวิดีโอ
|
||||
watch(() => isPlaying.value, (playing) => {
|
||||
if (playing) {
|
||||
saveProgressInterval.value = setInterval(async () => {
|
||||
if (videoRef.value && currentLesson.value) {
|
||||
// Send integers to avoid potential backend float issues
|
||||
const res = await saveVideoProgress(
|
||||
currentLesson.value.id,
|
||||
Math.floor(videoRef.value.currentTime),
|
||||
Math.floor(videoRef.value.duration || 0)
|
||||
)
|
||||
|
||||
// Update local completion state if backend reports completed
|
||||
if (res.success && res.data?.is_completed && courseData.value) {
|
||||
for (const chapter of courseData.value.chapters) {
|
||||
const lesson = chapter.lessons.find((l: any) => l.id === currentLesson.value.id)
|
||||
if (lesson) {
|
||||
if (!lesson.progress) lesson.progress = {}
|
||||
lesson.progress.is_completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 10000) // Every 10 seconds
|
||||
} else {
|
||||
clearInterval(saveProgressInterval.value)
|
||||
// Save one last time on pause
|
||||
if (videoRef.value && currentLesson.value) {
|
||||
saveVideoProgress(
|
||||
currentLesson.value.id,
|
||||
Math.floor(videoRef.value.currentTime),
|
||||
Math.floor(videoRef.value.duration || 0)
|
||||
)
|
||||
}
|
||||
// Event Listeners for Robustness
|
||||
onMounted(() => {
|
||||
// Page/Tab Visibility Logic
|
||||
if (import.meta.client) {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.addEventListener('pagehide', handlePageHide)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (import.meta.client) {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('pagehide', handlePageHide)
|
||||
}
|
||||
|
||||
// Final save attempt when component destroys
|
||||
performSaveProgress(true, true)
|
||||
})
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
console.log('Tab hidden, saving progress...')
|
||||
performSaveProgress(true, true) // Force save with keepalive
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageHide = () => {
|
||||
// Triggered on page refresh, close tab, or navigation
|
||||
performSaveProgress(true, true)
|
||||
}
|
||||
|
||||
// Watch Video Events
|
||||
watch(isPlaying, (playing) => {
|
||||
if (!playing) {
|
||||
// Paused: Save immediately
|
||||
performSaveProgress(true, false)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -260,40 +362,19 @@ watch(() => isPlaying.value, (playing) => {
|
|||
// เมื่อวิดีโอจบ ให้บันทึกว่าเรียนจบ (Complete)
|
||||
const onVideoEnded = async () => {
|
||||
isPlaying.value = false
|
||||
console.log('Video Ended')
|
||||
// Force save progress at 100%
|
||||
await performSaveProgress(true, false)
|
||||
|
||||
// Call explicit complete endpoint if exists
|
||||
if (currentLesson.value) {
|
||||
const res = await markLessonComplete(courseId.value, currentLesson.value.id)
|
||||
|
||||
if (res.success && res.data) {
|
||||
// 1. Update UI tick
|
||||
if (courseData.value) {
|
||||
for (const chapter of courseData.value.chapters) {
|
||||
const lesson = chapter.lessons.find((l: any) => l.id === currentLesson.value.id)
|
||||
if (lesson) {
|
||||
if (!lesson.progress) lesson.progress = {}
|
||||
lesson.progress.is_completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Play Next Lesson (if available)
|
||||
if (res.data.next_lesson_id) {
|
||||
// Optional: Add auto-play user preference check or delay
|
||||
// For now, let's just show a notification or auto-load after small delay
|
||||
// handleLessonSelect(res.data.next_lesson_id)
|
||||
|
||||
// Or just let user click next
|
||||
}
|
||||
|
||||
// 3. Handle Course Completion
|
||||
if (res.success) {
|
||||
markLessonAsCompletedLocally(currentLesson.value.id)
|
||||
if (res.data.is_course_completed) {
|
||||
// Maybe show a congratulation modal
|
||||
alert("ยินดีด้วย! คุณเรียนจบหลักสูตรแล้ว")
|
||||
}
|
||||
}
|
||||
|
||||
// Reload course data to ensure everything is synced (optional but safer)
|
||||
// await loadCourseData()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -423,10 +504,26 @@ onBeforeUnmount(() => {
|
|||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent transition-opacity opacity-0 group-hover:opacity-100">
|
||||
<div class="flex items-center gap-4 text-white">
|
||||
<q-btn flat round dense :icon="isPlaying ? 'pause' : 'play_arrow'" @click.stop="togglePlay" />
|
||||
<div class="relative flex-grow h-1 bg-white/30 rounded cursor-pointer" @click="seek">
|
||||
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded" :style="{ width: videoProgress + '%' }"></div>
|
||||
<div class="relative flex-grow h-1 bg-white/30 rounded cursor-pointer group/progress" @click="seek">
|
||||
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded group-hover/progress:h-1.5 transition-all" :style="{ width: videoProgress + '%' }"></div>
|
||||
</div>
|
||||
<span class="text-xs font-mono">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<div class="flex items-center gap-2 group/volume">
|
||||
<q-btn flat round dense :icon="volumeIcon" @click.stop="toggleMute" color="white" />
|
||||
<div class="w-0 group-hover/volume:w-20 overflow-hidden transition-all duration-300 flex items-center">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
:value="volume"
|
||||
@input="onVolumeChange"
|
||||
class="w-20 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -438,9 +535,27 @@ onBeforeUnmount(() => {
|
|||
|
||||
<p class="text-slate-700 dark:text-slate-300 text-base md:text-lg" v-if="currentLesson.description">{{ getLocalizedText(currentLesson.description) }}</p>
|
||||
|
||||
<!-- Lesson Content Area -->
|
||||
<!-- Lesson Content Area (Text/HTML) -->
|
||||
<div v-if="currentLesson.content" class="mt-6 prose dark:prose-invert max-w-none p-6 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)]">
|
||||
<div v-if="currentLesson.type === 'QUIZ'" class="mt-6 p-8 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)] text-center">
|
||||
<q-icon name="quiz" size="4rem" color="primary" class="mb-4" />
|
||||
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle', 'แบบทดสอบ') }}</h2>
|
||||
<p class="text-slate-500 mb-6">{{ getLocalizedText(currentLesson.quiz?.description || currentLesson.description) }}</p>
|
||||
|
||||
<div class="flex justify-center gap-4 text-sm text-slate-500 mb-8">
|
||||
<span v-if="currentLesson.quiz?.questions?.length"><q-icon name="format_list_numbered" /> {{ currentLesson.quiz.questions.length }} ข้อ</span>
|
||||
<span v-if="currentLesson.quiz?.time_limit"><q-icon name="schedule" /> {{ currentLesson.quiz.time_limit }} นาที</span>
|
||||
</div>
|
||||
|
||||
<q-btn
|
||||
color="primary"
|
||||
size="lg"
|
||||
rounded
|
||||
:label="$t('quiz.startBtn', 'เริ่มทำแบบทดสอบ')"
|
||||
icon="play_arrow"
|
||||
@click="$router.push(`/classroom/quiz?course_id=${courseId}&lesson_id=${currentLesson.id}`)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="currentLesson.content" class="mt-6 prose dark:prose-invert max-w-none p-6 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)]">
|
||||
<div v-html="getLocalizedText(currentLesson.content)" class="text-base md:text-lg leading-relaxed text-slate-900 dark:text-slate-200"></div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ definePageMeta({
|
|||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { fetchCourseLearningInfo, fetchLessonContent } = useCourse()
|
||||
const $q = useQuasar()
|
||||
const { fetchCourseLearningInfo, fetchLessonContent, submitQuiz: apiSubmitQuiz } = useCourse()
|
||||
|
||||
// State Management
|
||||
const currentScreen = ref<'start' | 'taking' | 'result' | 'review'>('start')
|
||||
|
|
@ -27,6 +28,28 @@ const lessonId = Number(route.query.lesson_id)
|
|||
const courseData = ref<any>(null)
|
||||
const quizData = ref<any>(null)
|
||||
const isLoading = ref(true)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// Quiz Taking State
|
||||
const currentQuestionIndex = ref(0)
|
||||
const userAnswers = ref<Record<number, number>>({}) // questionId -> choiceId
|
||||
const quizResult = ref<any>(null)
|
||||
|
||||
// Computed
|
||||
const currentQuestion = computed(() => {
|
||||
if (!quizData.value || !quizData.value.questions) return null
|
||||
return quizData.value.questions[currentQuestionIndex.value]
|
||||
})
|
||||
|
||||
const totalQuestions = computed(() => {
|
||||
return quizData.value?.questions?.length || 0
|
||||
})
|
||||
|
||||
const timerDisplay = computed(() => {
|
||||
const minutes = Math.floor(timeLeft.value / 60)
|
||||
const seconds = timeLeft.value % 60
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
// Helper for localization
|
||||
const getLocalizedText = (text: any) => {
|
||||
|
|
@ -47,6 +70,7 @@ const loadData = async () => {
|
|||
if (courseId && lessonId) {
|
||||
const lessonRes = await fetchLessonContent(courseId, lessonId)
|
||||
if (lessonRes.success) {
|
||||
// Determine if data is directly the quiz or nested
|
||||
quizData.value = lessonRes.data.quiz || lessonRes.data
|
||||
if (quizData.value?.time_limit) {
|
||||
timeLeft.value = quizData.value.time_limit * 60
|
||||
|
|
@ -60,25 +84,117 @@ const loadData = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Timer Logic
|
||||
const timerDisplay = computed(() => {
|
||||
const minutes = Math.floor(timeLeft.value / 60)
|
||||
const seconds = timeLeft.value % 60
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const startQuiz = () => {
|
||||
currentScreen.value = 'taking'
|
||||
timerInterval = setInterval(() => {
|
||||
if (timeLeft.value > 0) timeLeft.value--
|
||||
else submitQuiz(true)
|
||||
}, 1000)
|
||||
// Helper for shuffling
|
||||
const shuffleArray = <T>(array: T[]): T[] => {
|
||||
return array
|
||||
.map(value => ({ value, sort: Math.random() }))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.map(({ value }) => value)
|
||||
}
|
||||
|
||||
const submitQuiz = (auto = false) => {
|
||||
if (auto || confirm(t('quiz.submitConfirm'))) {
|
||||
if (timerInterval) clearInterval(timerInterval)
|
||||
currentScreen.value = 'result'
|
||||
// Quiz Actions
|
||||
const startQuiz = () => {
|
||||
// Deep copy to reset and apply shuffle
|
||||
const rawQuiz = JSON.parse(JSON.stringify(quizData.value))
|
||||
|
||||
if (rawQuiz) {
|
||||
// Shuffle Questions
|
||||
if (rawQuiz.shuffle_questions && rawQuiz.questions) {
|
||||
rawQuiz.questions = shuffleArray(rawQuiz.questions)
|
||||
}
|
||||
|
||||
// Shuffle Choices
|
||||
if (rawQuiz.shuffle_choices && rawQuiz.questions) {
|
||||
rawQuiz.questions.forEach((q: any) => {
|
||||
if (q.choices) {
|
||||
q.choices = shuffleArray(q.choices)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Update state with shuffled data
|
||||
quizData.value = rawQuiz
|
||||
}
|
||||
|
||||
currentScreen.value = 'taking'
|
||||
currentQuestionIndex.value = 0
|
||||
userAnswers.value = {}
|
||||
|
||||
if (quizData.value?.time_limit) {
|
||||
timeLeft.value = quizData.value.time_limit * 60
|
||||
timerInterval = setInterval(() => {
|
||||
if (timeLeft.value > 0) timeLeft.value--
|
||||
else submitQuiz(true)
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const selectAnswer = (choiceId: number) => {
|
||||
if (currentQuestion.value) {
|
||||
userAnswers.value[currentQuestion.value.id] = choiceId
|
||||
}
|
||||
}
|
||||
|
||||
const nextQuestion = () => {
|
||||
if (!currentQuestion.value) return
|
||||
|
||||
// Check if answered
|
||||
if (!userAnswers.value[currentQuestion.value.id]) {
|
||||
// Show warning
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'),
|
||||
position: 'top',
|
||||
timeout: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (currentQuestionIndex.value < totalQuestions.value - 1) {
|
||||
currentQuestionIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
const prevQuestion = () => {
|
||||
if (currentQuestionIndex.value > 0) {
|
||||
currentQuestionIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
const retryQuiz = () => {
|
||||
currentScreen.value = 'start'
|
||||
quizResult.value = null
|
||||
}
|
||||
|
||||
const submitQuiz = async (auto = false) => {
|
||||
if (!auto && !confirm(t('quiz.submitConfirm'))) return
|
||||
|
||||
if (timerInterval) clearInterval(timerInterval)
|
||||
|
||||
isSubmitting.value = true
|
||||
currentScreen.value = 'result' // Switch to result screen immediately to show loader
|
||||
|
||||
try {
|
||||
// Prepare Payload
|
||||
const answersPayload = Object.entries(userAnswers.value).map(([qId, cId]) => ({
|
||||
question_id: Number(qId),
|
||||
choice_id: cId
|
||||
}))
|
||||
|
||||
// Call API
|
||||
const res = await apiSubmitQuiz(courseId, lessonId, answersPayload)
|
||||
|
||||
if (res.success) {
|
||||
quizResult.value = res.data
|
||||
} else {
|
||||
// Fallback error handling
|
||||
alert(res.error || 'Failed to submit quiz')
|
||||
// Maybe go back to taking?
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Submit quiz error:', err)
|
||||
alert('An unexpected error occurred.')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +230,7 @@ onUnmounted(() => {
|
|||
<span>{{ $t('quiz.exitTitle') }}</span>
|
||||
</button>
|
||||
<div class="w-[1px] h-4 bg-slate-300 dark:bg-white/10 mx-4"/>
|
||||
<h1 class="text-sm font-black text-slate-900 dark:text-white uppercase tracking-tight truncate max-w-[200px] md:max-w-md">
|
||||
<h1 class="text-sm font-black text-slate-900 dark:text-white uppercase tracking-tight truncate max-w-[200px] md:max-w-md hidden md:block">
|
||||
{{ quizData ? getLocalizedText(quizData.title) : (courseData ? getLocalizedText(courseData.course.title) : $t('quiz.startTitle')) }}
|
||||
</h1>
|
||||
</div>
|
||||
|
|
@ -151,7 +267,11 @@ onUnmounted(() => {
|
|||
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-2 tracking-tight">
|
||||
{{ quizData ? getLocalizedText(quizData.title) : $t('quiz.startTitle') }}
|
||||
</h2>
|
||||
<p class="text-[13px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest leading-none">
|
||||
<div class="flex justify-center gap-4 text-sm text-slate-500 mt-2">
|
||||
<span v-if="quizData?.questions?.length"><q-icon name="format_list_numbered" /> {{ quizData.questions.length }} {{ $t('quiz.questions') }}</span>
|
||||
<span v-if="quizData?.time_limit"><q-icon name="schedule" /> {{ quizData.time_limit }} {{ $t('quiz.minutes') }}</span>
|
||||
</div>
|
||||
<p class="text-[13px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest leading-none mt-6">
|
||||
{{ $t('quiz.preparationTitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -181,43 +301,121 @@ onUnmounted(() => {
|
|||
</div>
|
||||
|
||||
<!-- 2. TAKING SCREEN -->
|
||||
<div v-if="currentScreen === 'taking'" class="w-full max-w-[840px] animate-fade-in py-12 text-center">
|
||||
<div class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-10 shadow-xl">
|
||||
<div v-if="quizData?.questions && quizData.questions.length > 0">
|
||||
<!-- Questions iterate here if available -->
|
||||
<div class="text-slate-500">{{ $t('quiz.underDevelopment') }}</div>
|
||||
<div v-if="currentScreen === 'taking'" class="w-full max-w-[840px] animate-fade-in py-12">
|
||||
<div v-if="currentQuestion" class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-12 shadow-xl relative overflow-hidden">
|
||||
<!-- Progress Bar -->
|
||||
<div class="absolute top-0 left-0 right-0 h-1.5 bg-slate-100 dark:bg-white/5">
|
||||
<div class="h-full bg-blue-500 transition-all duration-300" :style="{ width: ((currentQuestionIndex + 1) / totalQuestions) * 100 + '%' }"></div>
|
||||
</div>
|
||||
<div v-else class="mb-10">
|
||||
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.noQuizData') }}</h2>
|
||||
<p class="text-slate-500 dark:text-slate-400">{{ $t('quiz.noQuizDesc') }}</p>
|
||||
|
||||
<div class="flex justify-between items-start mb-8 mt-2">
|
||||
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">Question {{ currentQuestionIndex + 1 }} / {{ totalQuestions }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pt-8 border-t border-slate-100 dark:border-white/5">
|
||||
<button @click="confirmExit" class="px-8 py-4 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-2xl font-bold hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors">
|
||||
{{ $t('quiz.backToLesson') }}
|
||||
</button>
|
||||
|
||||
<!-- Question Title -->
|
||||
<h3 class="text-xl md:text-2xl font-bold text-slate-900 dark:text-white mb-10 leading-relaxed">
|
||||
{{ getLocalizedText(currentQuestion.question) }}
|
||||
</h3>
|
||||
|
||||
<!-- Choices -->
|
||||
<div class="flex flex-col gap-4 mb-12">
|
||||
<button
|
||||
v-for="choice in currentQuestion.choices"
|
||||
:key="choice.id"
|
||||
@click="selectAnswer(choice.id)"
|
||||
class="group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 flex items-center gap-4"
|
||||
:class="userAnswers[currentQuestion.id] === choice.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10'
|
||||
: 'border-slate-100 dark:border-white/10 hover:border-blue-200 dark:hover:border-blue-500/30 bg-transparent'"
|
||||
>
|
||||
<div class="w-6 h-6 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors"
|
||||
:class="userAnswers[currentQuestion.id] === choice.id ? 'border-blue-500 bg-blue-500' : 'border-slate-300 dark:border-white/20 group-hover:border-blue-400'"
|
||||
>
|
||||
<q-icon v-if="userAnswers[currentQuestion.id] === choice.id" name="check" size="xs" class="text-white" />
|
||||
</div>
|
||||
<span class="text-slate-700 dark:text-slate-200 font-medium text-lg">{{ getLocalizedText(choice.text) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-between items-center pt-8 border-t border-slate-100 dark:border-white/5">
|
||||
<button
|
||||
@click="prevQuestion"
|
||||
:disabled="currentQuestionIndex === 0"
|
||||
class="px-6 py-3 rounded-xl font-bold text-slate-500 hover:bg-slate-50 dark:hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<q-icon name="arrow_back" /> {{ $t('common.back', 'ย้อนกลับ') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="currentQuestionIndex < totalQuestions - 1"
|
||||
@click="nextQuestion"
|
||||
class="px-8 py-3 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-xl font-bold hover:opacity-90 transition-all flex items-center gap-2"
|
||||
>
|
||||
{{ $t('common.next', 'ถัดไป') }} <q-icon name="arrow_forward" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="submitQuiz(false)"
|
||||
class="px-8 py-3 bg-blue-600 text-white rounded-xl font-bold hover:bg-blue-500 transition-all shadow-lg shadow-blue-500/30 flex items-center gap-2"
|
||||
>
|
||||
{{ $t('quiz.submitValues') }} <q-icon name="check" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. RESULT SCREEN -->
|
||||
<div v-if="currentScreen === 'result'" class="w-full max-w-[640px] animate-fade-in py-12">
|
||||
<div class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[40px] p-10 shadow-2xl text-center">
|
||||
<div class="mb-8">
|
||||
<div class="w-24 h-24 bg-emerald-50 dark:bg-emerald-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<q-icon name="check_circle" size="3rem" class="text-emerald-500" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-black mb-2 text-slate-900 dark:text-white">{{ $t('quiz.scoreTitle') }}</h2>
|
||||
<p class="text-slate-500 dark:text-slate-400">{{ $t('quiz.passMessage') }}</p>
|
||||
<div class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[40px] p-10 shadow-2xl text-center relative overflow-hidden">
|
||||
|
||||
<div v-if="isSubmitting" class="absolute inset-0 bg-white/80 dark:bg-slate-900/80 z-20 flex flex-col items-center justify-center">
|
||||
<q-spinner color="primary" size="3em" />
|
||||
<p class="mt-4 font-bold animate-pulse">{{ $t('quiz.submitting') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 mt-8">
|
||||
<div class="mb-8 relative z-10">
|
||||
<div class="w-28 h-28 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl border-4"
|
||||
:class="quizResult?.is_passed ? 'bg-emerald-50 dark:bg-emerald-500/10 border-emerald-100 dark:border-emerald-500/20' : 'bg-red-50 dark:bg-red-500/10 border-red-100 dark:border-red-500/20'"
|
||||
>
|
||||
<q-icon :name="quizResult?.is_passed ? 'emoji_events' : 'close'" size="4rem" :color="quizResult?.is_passed ? 'positive' : 'negative'" />
|
||||
</div>
|
||||
<h2 class="text-3xl font-black mb-2 text-slate-900 dark:text-white uppercase tracking-tight">
|
||||
{{ quizResult?.is_passed ? $t('quiz.resultPassed') : $t('quiz.resultFailed') }}
|
||||
</h2>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium text-lg">
|
||||
{{ quizResult?.is_passed ? $t('quiz.passMessage') : $t('quiz.failMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Score Card -->
|
||||
<div class="bg-slate-50 dark:bg-[#0b121f] rounded-3xl p-6 mb-8 flex items-center justify-around border border-slate-100 dark:border-white/5">
|
||||
<div class="text-center">
|
||||
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.scoreLabel') }}</div>
|
||||
<div class="text-4xl font-black text-blue-600 dark:text-blue-400">{{ quizResult?.score }}<span class="text-lg text-slate-400 font-bold">/{{ quizResult?.total_score }}</span></div>
|
||||
</div>
|
||||
<div class="w-[1px] h-12 bg-slate-200 dark:bg-white/10"></div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.correctLabel') }}</div>
|
||||
<div class="text-4xl font-black text-emerald-500">{{ quizResult?.correct_answers }}<span class="text-lg text-slate-400 font-bold">/{{ quizResult?.total_questions }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 relative z-10">
|
||||
<button
|
||||
@click="confirmExit"
|
||||
class="w-full py-5 bg-blue-600 text-white rounded-[24px] font-black text-sm shadow-lg shadow-blue-500/20"
|
||||
class="w-full py-4 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-[20px] font-black text-sm hover:opacity-90 transition-all"
|
||||
>
|
||||
{{ $t('quiz.backToLesson') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!quizResult?.is_passed"
|
||||
@click="retryQuiz"
|
||||
class="w-full py-4 text-slate-500 hover:text-slate-800 dark:hover:text-white font-bold text-sm transition-colors"
|
||||
>
|
||||
{{ $t('quiz.retryBtn') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue