feat: Implement core classroom functionality including video player, learning and quiz pages, course detail view, and i18n support.

This commit is contained in:
supalerk-ar66 2026-02-12 16:05:37 +07:00
parent 008f712480
commit 7f5119e5aa
9 changed files with 289 additions and 109 deletions

View file

@ -90,7 +90,7 @@ const getLocalizedText = (text: any) => {
</div>
<div v-else class="p-10 flex flex-col items-center justify-center text-slate-400">
<q-icon name="campaign" size="40px" class="mb-2 opacity-50" />
<p>{{ $t('classroom.noAnnouncements', 'ไม่มีประกาศในขณะนี้') }}</p>
<p>{{ $t('classroom.noAnnouncements') }}</p>
</div>
</q-card-section>
</q-card>

View file

@ -61,7 +61,7 @@ const progressPercentage = computed(() => {
<!-- Course Progress Header -->
<div class="p-5 border-b border-gray-200 dark:border-white/10 bg-slate-50/50 dark:bg-slate-900/50">
<div class="flex justify-between items-center mb-2">
<span class="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-400">{{ $t('course.progress', 'ความคืบหน้า') }}</span>
<span class="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
<span class="text-sm font-black text-blue-600 dark:text-blue-400">{{ progressPercentage }}%</span>
</div>
<div class="h-2 w-full bg-slate-200 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">

View file

@ -165,9 +165,21 @@ const togglePlay = () => {
return;
}
if (!videoRef.value) return;
if (isPlaying.value) videoRef.value.pause();
else videoRef.value.play();
isPlaying.value = !isPlaying.value;
if (isPlaying.value) {
videoRef.value.pause();
isPlaying.value = false;
} else {
const playPromise = videoRef.value.play();
if (playPromise !== undefined) {
playPromise.then(() => {
isPlaying.value = true;
}).catch(error => {
// Auto-play was prevented or play was interrupted
// We can safely ignore this error
console.log("Video play request handled:", error.name);
});
}
}
};
const handleTimeUpdate = () => {
@ -238,12 +250,12 @@ watch([volume, isMuted], () => {
></iframe>
<!-- 2. Standard HTML5 Video Player -->
<div v-else class="w-full h-full relative">
<div v-else class="w-full h-full relative group/video cursor-pointer">
<video
ref="videoRef"
:src="src"
:poster="poster"
class="w-full h-full object-contain"
class="w-full h-full object-contain bg-slate-900"
@click="togglePlay"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
@ -251,26 +263,30 @@ watch([volume, isMuted], () => {
/>
<!-- Custom Controls Overlay (Only for HTML5 Video) -->
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 via-black/40 to-transparent transition-opacity opacity-0 group-hover:opacity-100">
<div class="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/90 via-black/40 to-transparent transition-opacity opacity-0 group-hover/video:opacity-100 flex flex-col gap-3">
<!-- Progress Bar -->
<div class="relative flex-grow h-1.5 bg-white/20 rounded-full cursor-pointer group/progress overflow-hidden" @click="seek">
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded-full group-hover/progress:bg-blue-400 transition-all shadow-[0_0_12px_rgba(59,130,246,0.6)]" :style="{ width: videoProgress + '%' }"></div>
</div>
<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.5 bg-white/20 rounded-full cursor-pointer group/progress overflow-hidden" @click="seek">
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded-full group-hover/progress:bg-blue-400 transition-all shadow-[0_0_10px_rgba(59,130,246,0.5)]" :style="{ width: videoProgress + '%' }"></div>
</div>
<span class="text-xs font-mono font-medium opacity-90">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
<q-btn flat round dense :icon="isPlaying ? 'pause' : 'play_arrow'" @click.stop="togglePlay" class="hover:scale-110 active:scale-95 transition-transform" />
<span class="text-xs font-mono font-bold opacity-80">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
<div class="flex-grow"></div>
<!-- Volume Control -->
<div class="flex items-center gap-2 group/volume">
<q-btn flat round dense :icon="volumeIcon" @click.stop="handleToggleMute" color="white" />
<div class="w-0 group-hover/volume:w-20 overflow-hidden transition-all duration-300 flex items-center">
<div class="flex items-center gap-2 group/volume relative">
<q-btn flat round dense :icon="volumeIcon" @click.stop="handleToggleMute" color="white" class="hover:scale-110 transition-transform" />
<div class="w-0 group-hover/volume:w-24 overflow-hidden transition-all duration-300 flex items-center bg-black/60 backdrop-blur-md rounded-full px-2">
<input
type="range"
min="0"
max="1"
step="0.1"
step="0.05"
:value="volume"
@input="handleVolumeChange"
class="w-20 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer accent-blue-500"
class="w-20 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer accent-blue-500 mx-2"
/>
</div>
</div>

View file

@ -156,7 +156,7 @@ const handleEnroll = () => {
<div class="relative">
<div v-if="course.price > 0" class="mb-4">
<span class="text-xs font-black uppercase tracking-widest text-slate-400 mb-1 block">{{ $t('course.price', 'ราคาคอร์ส') }}</span>
<span class="text-xs font-black uppercase tracking-widest text-slate-400 mb-1 block">{{ $t('course.price') }}</span>
<div class="text-4xl font-black font-display">
<span class="text-slate-900 dark:text-white">
{{ formatPrice(course.price) }}
@ -180,13 +180,13 @@ const handleEnroll = () => {
</q-btn>
<div class="mt-6 space-y-4">
<p class="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-4">{{ $t('course.includes', 'คอร์สนี้รวมอะไรบ้าง') }}</p>
<p class="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-4">{{ $t('course.includes') }}</p>
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300 font-bold">
<div class="w-6 h-6 rounded-lg bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center">
<q-icon name="all_inclusive" size="14px" class="text-blue-600 dark:text-blue-400" />
</div>
{{ $t('course.fullLifetimeAccess', 'เข้าเรียนได้ตลอดชีพ') }}
{{ $t('course.fullLifetimeAccess') }}
</div>
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300 font-bold">
@ -200,7 +200,7 @@ const handleEnroll = () => {
<div class="w-6 h-6 rounded-lg bg-purple-50 dark:bg-purple-500/10 flex items-center justify-center">
<q-icon name="devices" size="14px" class="text-purple-600 dark:text-purple-400" />
</div>
{{ $t('course.accessOnMobile', 'เข้าเรียนได้ทุกอุปกรณ์') }}
{{ $t('course.accessOnMobile') }}
</div>
</div>
</div>

View file

@ -212,6 +212,15 @@
"instructionTitle": "Instructions",
"instruction1": "Pay attention to the questions to measure your learning progress.",
"startBtn": "Start Quiz",
"warningTitle": "Warning",
"singleAttemptWarning": "This quiz can only be taken once. If you fail, you will not be able to try again. Do you want to continue?",
"continue": "Continue",
"alreadyPassed": "You have already passed",
"latestScore": "Latest Score",
"retryMaybe": "Try Again",
"passedStatus": "Passed",
"failedStatus": "Failed",
"passingScore": "Passing Score",
"nextBtn": "Next Question",
"prevBtn": "Previous Question",
"submitBtn": "Submit Answers",

View file

@ -212,6 +212,15 @@
"instructionTitle": "คำแนะนำ",
"instruction1": "แบบทดสอบนี้มีไว้เพื่อวัดความรู้ความเข้าใจของคุณในบทเรียนนี้",
"startBtn": "เริ่มทำแบบทดสอบ",
"warningTitle": "คำเตือน",
"singleAttemptWarning": "แบบทดสอบนี้สามารถทำได้เพียงครั้งเดียวเท่านั้น หากไม่ผ่านคุณจะไม่สามารถทำใหม่ได้อีก คุณต้องการดำเนินการต่อหรือไม่?",
"continue": "ดำเนินการต่อ",
"alreadyPassed": "คุณสอบผ่านเกณฑ์แล้ว",
"latestScore": "คะแนนล่าสุด",
"retryMaybe": "ลองใหม่อีกครั้ง",
"passedStatus": "ผ่านเกณฑ์",
"failedStatus": "ไม่ผ่านเกณฑ์",
"passingScore": "เกณฑ์การผ่าน",
"exitTitle": "ออกจากแบบทดสอบ",
"timeLeft": "เวลาที่เหลือ",
"submitConfirm": "คุณต้องการส่งคำตอบหรือไม่?",

View file

@ -6,9 +6,8 @@
* ออกแบบใหเหมอนระบบ LMS มาตรฐาน
*/
definePageMeta({
layout: false, // Custom layout defined within this component
layout: false,
middleware: 'auth'
})
@ -21,10 +20,9 @@ const router = useRouter()
const { t } = useI18n()
const { user } = useAuth()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements, markLessonComplete, getLocalizedText } = useCourse()
// Media Prefs (Global Volume)
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs()
const $q = useQuasar()
// State
// State management
const sidebarOpen = ref(false)
const courseId = computed(() => Number(route.query.course_id))
@ -99,6 +97,83 @@ const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
// Logic Quiz Attempt Management
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
// If never attempted
if (!latestAttempt) {
return {
canStart: true,
label: t('quiz.startBtn'),
icon: 'play_arrow',
showScore: false
}
}
// If multiple attempts allowed
if (allowMultiple) {
return {
canStart: true,
label: t('quiz.retryBtn'),
icon: 'refresh',
showScore: true,
score: latestAttempt.score,
isPassed: latestAttempt.is_passed
}
}
// allowMultiple is false (Single attempt only)
// Lock the quiz regardless of pass/fail once attempted
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
// If multiple attempts are disabled and it's the first time
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}`)
}
}
// Helper (Hard Reload)
const resetAndNavigate = (path: string) => {
if (import.meta.client) {
@ -117,18 +192,18 @@ const resetAndNavigate = (path: string) => {
}
}
// 2. localStorage
// 2. Clear all localStorage
localStorage.clear()
// 3.
// 3. Restore whitelisted keys
Object.entries(whitelist).forEach(([key, value]) => {
localStorage.setItem(key, value)
})
// 4. (Hard Reload) path
// 4. Force hard reload to the new path
window.location.href = path
} else {
// Fallback SSR
// SSR Fallback
router.push(path)
}
}
@ -137,13 +212,13 @@ const resetAndNavigate = (path: string) => {
const handleLessonSelect = (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return
// 1. URL
// 1. Update URL query params
router.push({ query: { ...route.query, lesson_id: lessonId.toString() } })
// 2. Refresh
// 2. Load content without refresh
loadLesson(lessonId)
// Sidebar
// Close sidebar on mobile
if (sidebarOpen.value) {
sidebarOpen.value = false
}
@ -169,14 +244,12 @@ const loadCourseData = async () => {
if (res.success) {
courseData.value = res.data
// Auto-load logic: URL
// Auto-load logic: Check URL first, fallback to first available lesson
const urlLessonId = route.query.lesson_id ? Number(route.query.lesson_id) : null
if (urlLessonId) {
// URL
loadLesson(urlLessonId)
} else if (!currentLesson.value) {
// URL
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]
@ -184,7 +257,7 @@ const loadCourseData = async () => {
}
}
// Fetch Announcements
// Fetch Course Announcements
const annRes = await fetchCourseAnnouncements(courseId.value)
if (annRes.success) {
announcements.value = annRes.data || []
@ -192,7 +265,7 @@ const loadCourseData = async () => {
}
}
} catch (error) {
console.error('Error loading course:', error)
console.error('Error loading course data:', error)
} finally {
isLoading.value = false
}
@ -483,14 +556,13 @@ const videoSrc = computed(() => {
// (Complete)
const onVideoEnded = async () => {
// Safety check BEFORE trying to save
// Safety check before saving
const lesson = currentLesson.value
if (!lesson || !lesson.progress || lesson.progress.is_completed || isCompleting.value) return
isCompleting.value = true
try {
// 1. Force save progress at 100%
// This will trigger the backend's auto-complete logic
// Force save progress at 100% to trigger backend completion
await performSaveProgress(true, false)
} catch (err) {
console.error('Failed to save progress on end:', err)
@ -539,7 +611,7 @@ onBeforeUnmount(() => {
@click="toggleSidebar"
>
<q-icon name="format_list_bulleted" size="18px" class="mr-1.5" />
<span class="hidden md:inline">{{ $t('classroom.curriculum', 'เนื้อหาหลักสูตร') }}</span>
<span class="hidden md:inline">{{ $t('classroom.curriculum') }}</span>
</q-btn>
<q-toolbar-title class="text-base font-bold text-left truncate text-slate-900 dark:text-white">
@ -557,7 +629,7 @@ onBeforeUnmount(() => {
class="text-slate-600 dark:text-slate-300 hover:text-blue-600 transition-colors"
>
<q-badge v-if="hasUnreadAnnouncements" color="red" floating rounded />
<q-tooltip>{{ $t('classroom.announcements', 'ประกาศในคอร์ส') }}</q-tooltip>
<q-tooltip>{{ $t('classroom.announcements') }}</q-tooltip>
</q-btn>
</div>
</q-toolbar>
@ -580,39 +652,46 @@ onBeforeUnmount(() => {
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]">
<!-- Video Player & Content Area -->
<div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow">
<!-- Video Player -->
<VideoPlayer
v-if="currentLesson && videoSrc && !isLessonLoading"
ref="videoPlayerComp"
:src="videoSrc"
:poster="courseData?.course?.thumbnail_url"
:initialSeekTime="initialSeekTime"
@timeupdate="handleVideoTimeUpdate"
@ended="onVideoEnded"
@loadedmetadata="(d: number) => onVideoMetadataLoaded(d)"
/>
<!-- Skeleton Loader for Video/Content -->
<div v-if="isLessonLoading" class="aspect-video bg-slate-200 dark:bg-slate-800 rounded-2xl animate-pulse flex items-center justify-center mb-6 overflow-hidden relative shadow-xl">
<!-- Course Thumbnail as a background during loading -->
<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">
<div class="relative">
<q-spinner size="4rem" color="primary" :thickness="4" />
<q-icon name="play_circle" size="2rem" color="primary" class="absolute inset-0 m-auto opacity-50" />
<!-- 1. LOADING STATE (Comprehensive Skeleton) -->
<div v-if="isLessonLoading" class="animate-fade-in">
<!-- Video Skeleton -->
<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>
</div>
<p class="mt-4 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-[0.2em] animate-pulse">{{ $t('common.loading') }}</p>
</div>
<!-- Info Skeleton -->
<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>
</div>
</div>
<!-- Lesson Info -->
<div v-if="currentLesson" class="bg-[var(--bg-surface)] p-6 md:p-8 rounded-3xl shadow-sm border border-[var(--border-color)]">
<!-- 2. READY STATE (Real Lesson Content) -->
<div v-else-if="currentLesson" class="animate-fade-in">
<!-- Video Player -->
<VideoPlayer
v-if="videoSrc"
ref="videoPlayerComp"
:src="videoSrc"
:poster="courseData?.course?.thumbnail_url"
:initialSeekTime="initialSeekTime"
@timeupdate="handleVideoTimeUpdate"
@ended="onVideoEnded"
@loadedmetadata="(d: number) => onVideoMetadataLoaded(d)"
/>
<!-- Lesson Info -->
<div class="bg-[var(--bg-surface)] p-6 md:p-8 rounded-3xl shadow-sm border border-[var(--border-color)]">
<!-- ใชจากตวแปรกลาง: จะแยกโหมดใหตโนม (สวาง=ดำ / =ขาว) -->
<div class="flex items-start justify-between gap-4 mb-4">
<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>
@ -625,27 +704,51 @@ onBeforeUnmount(() => {
<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">
<q-icon name="quiz" size="40px" />
</div>
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle', 'แบบทดสอบท้ายบทเรียน') }}</h2>
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-6 max-w-md mx-auto">{{ getLocalizedText(currentLesson.quiz?.description || currentLesson.description) }}</p>
<div class="flex justify-center flex-wrap gap-3 text-sm mb-8">
<span v-if="currentLesson.quiz?.questions?.length" class="px-3 py-1 bg-white dark:bg-white rounded-full border border-gray-200 dark:border-white/10 shadow-sm flex items-center gap-1.5 text-slate-900 font-bold">
<q-icon name="format_list_numbered" size="14px" class="text-blue-600" /> {{ currentLesson.quiz.questions.length }} {{ $t('quiz.questions') }}
<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') }}
</span>
<span v-if="currentLesson.quiz?.time_limit" class="px-3 py-1 bg-white dark:bg-white rounded-full border border-gray-200 dark:border-white/10 shadow-sm flex items-center gap-1.5 text-slate-900 font-bold">
<q-icon name="schedule" size="14px" class="text-orange-600" /> {{ currentLesson.quiz.time_limit }} {{ $t('quiz.minutes') }}
<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') }}
</span>
</div>
<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>
<q-btn
v-if="quizStatus?.canStart"
class="bg-blue-600 text-white shadow-lg shadow-blue-600/30 hover:shadow-blue-600/50 transition-all font-bold px-8"
size="lg"
rounded
no-caps
:label="$t('quiz.startBtn')"
icon="play_arrow"
@click="$router.push(`/classroom/quiz?course_id=${courseId}&lesson_id=${currentLesson.id}`)"
:label="quizStatus.label"
:icon="quizStatus.icon"
@click="handleStartQuiz"
/>
<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>
</div>
<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>
@ -682,9 +785,10 @@ onBeforeUnmount(() => {
<q-icon name="download" class="text-slate-300 group-hover:text-blue-500 z-10" />
</a>
</div>
</div>
</div>
</div>
</div> <!-- End Attachments -->
</div> <!-- End Lesson Info -->
</div> <!-- End Ready State Wrapper -->
</div> <!-- End Main Content Wrapper -->
</q-page>
</q-page-container>

View file

@ -83,7 +83,7 @@ const jumpToQuestion = (targetIndex: number) => {
if (!isAnswered && !isSkippable) {
$q.notify({
type: 'warning',
message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'),
message: t('quiz.pleaseSelectAnswer'),
position: 'top',
timeout: 2000
})
@ -106,6 +106,8 @@ const totalQuestions = computed(() => {
return quizData.value?.questions?.length || 0
})
const hasQuestions = computed(() => totalQuestions.value > 0)
const showQuestionMap = computed(() => $q.screen.gt.sm)
const timerDisplay = computed(() => {
@ -252,12 +254,37 @@ const submitQuiz = async (auto = false) => {
return
}
// Confirmation before submission
if (!confirm(t('quiz.submitConfirm', 'ยืนยันการส่งคำตอบ?'))) {
return
}
// Premium Confirmation before submission
$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.submitConfirm')}</div>`,
html: true,
persistent: true,
class: 'rounded-[24px]',
ok: {
label: t('common.ok'),
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(() => {
processSubmitQuiz(auto)
})
return
}
processSubmitQuiz(auto)
}
const processSubmitQuiz = async (auto = false) => {
// 2. Start Submission Process
if (timerInterval) clearInterval(timerInterval)
@ -285,12 +312,13 @@ const submitQuiz = async (auto = false) => {
}
} else {
// Fallback error handling
alert(res.error || 'Failed to submit quiz')
// Maybe go back to taking?
$q.notify({
type: 'negative',
message: res.error || 'Failed to submit quiz'
})
}
} catch (err) {
console.error('Submit quiz error:', err)
alert('An unexpected error occurred.')
} finally {
isSubmitting.value = false
}
@ -349,7 +377,7 @@ const getCorrectChoiceId = (questionId: number) => {
</button>
<div class="w-[1px] h-4 bg-slate-300 dark:bg-white/10 mx-4"/>
<h1 class="text-base font-bold text-slate-900 dark:text-white truncate max-w-[200px] md:max-w-md hidden md:block">
{{ currentScreen === 'review' ? $t('quiz.reviewAnswers', 'เฉลยคำตอบ') : (quizData ? getLocalizedText(quizData.title) : (courseData ? getLocalizedText(courseData.course.title) : $t('quiz.startTitle'))) }}
{{ currentScreen === 'review' ? $t('quiz.reviewAnswers') : (quizData ? getLocalizedText(quizData.title) : (courseData ? getLocalizedText(courseData.course.title) : $t('quiz.startTitle'))) }}
</h1>
</div>
@ -370,6 +398,22 @@ const getCorrectChoiceId = (questionId: number) => {
<p class="text-sm font-medium text-slate-500">{{ $t('classroom.loadingTitle') }}</p>
</div>
<div v-else-if="!quizData || !hasQuestions" 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-[32px] p-8 md:p-14 shadow-lg text-center">
<div class="w-20 h-20 rounded-3xl bg-amber-50 dark:bg-amber-500/10 border border-amber-100 dark:border-amber-500/20 flex items-center justify-center mx-auto mb-6">
<q-icon name="warning" size="2.5rem" color="warning" />
</div>
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-2">{{ $t('quiz.noQuizData') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-8">{{ $t('quiz.noQuizDesc') }}</p>
<button
class="px-8 py-3 bg-blue-600 text-white rounded-xl hover:bg-blue-500 transition-all font-black"
@click="confirmExit"
>
{{ $t('quiz.backToLesson') }}
</button>
</div>
</div>
<template v-else>
<!-- 1. START SCREEN -->
<div v-if="currentScreen === 'start'" class="w-full max-w-[640px] animate-fade-in py-12">
@ -476,7 +520,7 @@ const getCorrectChoiceId = (questionId: number) => {
:disabled="currentQuestionIndex === 0"
class="px-6 py-3 rounded-xl font-bold text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-white/5 hover:bg-slate-200 dark:hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all flex items-center gap-2"
>
<q-icon name="arrow_back" /> {{ $t('common.back', 'ย้อนกลับ') }}
<q-icon name="arrow_back" /> {{ $t('common.back') }}
</button>
<button
@ -484,7 +528,7 @@ const getCorrectChoiceId = (questionId: number) => {
@click="nextQuestion"
class="px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition-all shadow-lg shadow-blue-500/20 flex items-center gap-2"
>
{{ $t('common.next', 'ถัดไป') }} <q-icon name="arrow_forward" />
{{ $t('common.next') }} <q-icon name="arrow_forward" />
</button>
<button
v-else
@ -556,7 +600,7 @@ const getCorrectChoiceId = (questionId: number) => {
@click="reviewQuiz"
class="w-full py-2 text-blue-500 hover:text-blue-700 dark:hover:text-blue-400 font-bold text-sm transition-colors mt-2"
>
{{ $t('quiz.reviewAnswers', 'ดูเฉลยคำตอบ') }}
{{ $t('quiz.reviewAnswers') }}
</button>
</div>
</div>

View file

@ -24,7 +24,7 @@ onMounted(async () => {
if (!token) {
isLoading.value = false
isSuccess.value = false
errorMessage.value = t('auth.invalidToken') || 'Token ไม่ถูกต้อง'
errorMessage.value = t('auth.invalidToken')
return
}
@ -38,15 +38,13 @@ onMounted(async () => {
} else {
isSuccess.value = false
if (result.code === 400) {
errorMessage.value = t('profile.emailAlreadyVerified') || 'อีเมลได้รับการยืนยันแล้ว'
// Treat as success visually or show specific message?
// Requirement says "check mark" for done.
// If already verified, maybe show success-like state with "Already Verified" message.
isSuccess.value = true // Let's show checkmark but with specific message
errorMessage.value = t('profile.emailAlreadyVerified')
// If already verified, show success state with specific message
isSuccess.value = true
} else if (result.code === 401) {
errorMessage.value = t('auth.tokenExpired') || 'Token หมดอายุหรือล้มเหลว'
errorMessage.value = t('auth.tokenExpired')
} else {
errorMessage.value = result.error || 'ยืนยันอีเมลไม่สำเร็จ'
errorMessage.value = result.error || t('common.error')
}
}
})
@ -64,7 +62,7 @@ const navigateToHome = () => {
<div v-if="isLoading" class="flex flex-col items-center justify-center py-8">
<q-spinner-dots size="4rem" color="primary" />
<h2 class="mt-6 text-xl font-bold text-slate-900 dark:text-white animate-pulse">
{{ $t('auth.verifyingEmail') || 'กำลังยืนยันอีเมล...' }}
{{ $t('auth.verifyingEmail') }}
</h2>
</div>
@ -75,10 +73,10 @@ const navigateToHome = () => {
</div>
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-2">
{{ errorMessage && errorMessage !== '' ? (errorMessage) : ($t('auth.emailVerified') || 'ยืนยันอีเมลสำเร็จ!') }}
{{ errorMessage && errorMessage !== '' ? (errorMessage) : ($t('auth.emailVerified')) }}
</h2>
<p class="text-slate-500 dark:text-slate-400 mb-8">
{{ $t('auth.emailVerifiedDesc') || 'บัญชีของคุณได้รับการยืนยันเรียบร้อยแล้ว' }}
{{ $t('auth.emailVerifiedDesc') }}
</p>
<q-btn
@ -86,7 +84,7 @@ const navigateToHome = () => {
rounded
color="primary"
class="w-full py-3 font-bold text-lg shadow-lg shadow-blue-500/30"
:label="$t('common.backToHome') || 'กลับสู่หน้าหลัก'"
:label="$t('common.backToHome')"
@click="navigateToHome"
/>
</div>
@ -109,7 +107,7 @@ const navigateToHome = () => {
rounded
color="slate-700"
class="w-full py-3 font-bold text-lg"
label="ลองใหม่อีกครั้ง"
:label="$t('common.tryAgain')"
@click="router.push('/')"
/>
</div>