feat: Implement core classroom functionality including video player, learning and quiz pages, course detail view, and i18n support.
This commit is contained in:
parent
008f712480
commit
7f5119e5aa
9 changed files with 289 additions and 109 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,15 @@
|
|||
"instructionTitle": "คำแนะนำ",
|
||||
"instruction1": "แบบทดสอบนี้มีไว้เพื่อวัดความรู้ความเข้าใจของคุณในบทเรียนนี้",
|
||||
"startBtn": "เริ่มทำแบบทดสอบ",
|
||||
"warningTitle": "คำเตือน",
|
||||
"singleAttemptWarning": "แบบทดสอบนี้สามารถทำได้เพียงครั้งเดียวเท่านั้น หากไม่ผ่านคุณจะไม่สามารถทำใหม่ได้อีก คุณต้องการดำเนินการต่อหรือไม่?",
|
||||
"continue": "ดำเนินการต่อ",
|
||||
"alreadyPassed": "คุณสอบผ่านเกณฑ์แล้ว",
|
||||
"latestScore": "คะแนนล่าสุด",
|
||||
"retryMaybe": "ลองใหม่อีกครั้ง",
|
||||
"passedStatus": "ผ่านเกณฑ์",
|
||||
"failedStatus": "ไม่ผ่านเกณฑ์",
|
||||
"passingScore": "เกณฑ์การผ่าน",
|
||||
"exitTitle": "ออกจากแบบทดสอบ",
|
||||
"timeLeft": "เวลาที่เหลือ",
|
||||
"submitConfirm": "คุณต้องการส่งคำตอบหรือไม่?",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue