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:
supalerk-ar66 2026-01-29 13:17:58 +07:00
parent 9232b6a21d
commit 4c575dc734
5 changed files with 570 additions and 169 deletions

View file

@ -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>