feat: Implement core e-learning classroom interface, user dashboard pages, authentication composable, and Thai localization.

This commit is contained in:
supalerk-ar66 2026-01-29 11:09:29 +07:00
parent 85d7c5c913
commit 9232b6a21d
5 changed files with 140 additions and 31 deletions

View file

@ -128,7 +128,8 @@ export const useAuth = () => {
// Shared state สำหรับเช็คว่ากำลังโหลดโปรไฟล์อยู่หรือไม่ เพื่อป้องกันการยิงซ้อน // Shared state สำหรับเช็คว่ากำลังโหลดโปรไฟล์อยู่หรือไม่ เพื่อป้องกันการยิงซ้อน
const isProfileLoading = useState<boolean>('auth_profile_loading', () => false) const isProfileLoading = useState<boolean>('auth_profile_loading', () => false)
const isProfileLoaded = useState<boolean>('auth_profile_loaded', () => false) // Init to true if we already have user data in cookie to avoid fetch on every hard refresh
const isProfileLoaded = useState<boolean>('auth_profile_loaded', () => !!user.value)
// ฟังก์ชันดึงข้อมูลโปรไฟล์ผู้ใช้ล่าสุด // ฟังก์ชันดึงข้อมูลโปรไฟล์ผู้ใช้ล่าสุด
const fetchUserProfile = async (forceRefresh = false) => { const fetchUserProfile = async (forceRefresh = false) => {

View file

@ -155,7 +155,8 @@
"notAvailable": "บทเรียนนี้ยังไม่เปิดให้เข้าชม", "notAvailable": "บทเรียนนี้ยังไม่เปิดให้เข้าชม",
"loadingTitle": "กำลังโหลด...", "loadingTitle": "กำลังโหลด...",
"chapter": "บทที่", "chapter": "บทที่",
"lessons": "บทเรียน" "lessons": "บทเรียน",
"attachments": "เอกสารประกอบ"
}, },
"quiz": { "quiz": {
"exitTitle": "ออกจากแบบทดสอบ", "exitTitle": "ออกจากแบบทดสอบ",

View file

@ -18,7 +18,7 @@ useHead({
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, markLessonComplete, checkLessonAccess } = useCourse() const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, markLessonComplete, checkLessonAccess, fetchVideoProgress } = useCourse()
// State // State
const sidebarOpen = ref(false) const sidebarOpen = ref(false)
@ -110,22 +110,61 @@ const loadLesson = async (lessonId: number) => {
// Optional: Check access first // Optional: Check access first
const accessRes = await checkLessonAccess(courseId.value, lessonId) const accessRes = await checkLessonAccess(courseId.value, lessonId)
if (accessRes.success && !accessRes.data.is_accessible) { if (accessRes.success && !accessRes.data.is_accessible) {
alert(t('classroom.notAvailable')) let msg = t('classroom.notAvailable')
// Handle specific lock reasons
if (accessRes.data.lock_reason) {
msg = accessRes.data.lock_reason
} else if (accessRes.data.required_quiz_pass && !accessRes.data.required_quiz_pass.is_passed) {
const quizTitle = getLocalizedText(accessRes.data.required_quiz_pass.title)
msg = `กรุณาทำแบบทดสอบ "${quizTitle}" ให้ผ่านก่อน`
} else if (accessRes.data.required_lessons && accessRes.data.required_lessons.length > 0) {
const reqLesson = accessRes.data.required_lessons.find((l: any) => !l.is_completed)
if (reqLesson) {
msg = `กรุณาเรียนบทเรียน "${getLocalizedText(reqLesson.title)}" ให้จบก่อน`
}
} else if (accessRes.data.is_enrolled === false) {
msg = 'คุณยังไม่ได้ลงทะเบียนในคอร์สนี้'
}
alert(msg)
isLessonLoading.value = false isLessonLoading.value = false
return return
} }
// 1. Fetch content
const res = await fetchLessonContent(courseId.value, lessonId) const res = await fetchLessonContent(courseId.value, lessonId)
if (res.success) { if (res.success) {
currentLesson.value = res.data currentLesson.value = res.data
// Restore progress if available // 2. Fetch progress separately (New Flow)
if (res.progress) { // Note: fetchLessonContent might return progress in some APIs, but here we use dedicated endpoint as requested
// Wait for video metadata to load usually, but set state const progressRes = await fetchVideoProgress(lessonId)
// We might set currentTime once metadata loaded
if (res.progress.video_progress_seconds > 0) { if (progressRes.success && progressRes.data) {
currentTime.value = res.progress.video_progress_seconds const p = progressRes.data
// We will apply this to videoRef locally when it is ready
// 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
}
}
}
} }
} }
} }
@ -164,9 +203,11 @@ const updateProgress = () => {
const videoSrc = computed(() => { const videoSrc = computed(() => {
if (!currentLesson.value) return '' if (!currentLesson.value) return ''
// Use explicit video_url from API first
if (currentLesson.value.video_url) return currentLesson.value.video_url
// Fallback (deprecated logic, but keeping just in case)
const content = getLocalizedText(currentLesson.value.content) const content = getLocalizedText(currentLesson.value.content)
// Check if content looks like a URL (starts with http/https or /)
// And doesn't contain obvious text indicators
if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) { if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) {
return content return content
} }
@ -177,16 +218,29 @@ const videoSrc = computed(() => {
// 3. (Progress Tracking) // 3. (Progress Tracking)
// ========================================== // ==========================================
// 10 // 10
// 10
watch(() => isPlaying.value, (playing) => { watch(() => isPlaying.value, (playing) => {
if (playing) { if (playing) {
saveProgressInterval.value = setInterval(() => { saveProgressInterval.value = setInterval(async () => {
if (videoRef.value && currentLesson.value) { if (videoRef.value && currentLesson.value) {
// Send integers to avoid potential backend float issues // Send integers to avoid potential backend float issues
saveVideoProgress( const res = await saveVideoProgress(
currentLesson.value.id, currentLesson.value.id,
Math.floor(videoRef.value.currentTime), Math.floor(videoRef.value.currentTime),
Math.floor(videoRef.value.duration || 0) 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 }, 10000) // Every 10 seconds
} else { } else {
@ -202,15 +256,44 @@ watch(() => isPlaying.value, (playing) => {
} }
}) })
// (Complete)
// (Complete) // (Complete)
const onVideoEnded = async () => { const onVideoEnded = async () => {
isPlaying.value = false isPlaying.value = false
if (currentLesson.value) { if (currentLesson.value) {
await markLessonComplete(courseId.value, currentLesson.value.id) const res = await markLessonComplete(courseId.value, currentLesson.value.id)
//
await loadCourseData()
// Auto play next logic could go here 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.data.is_course_completed) {
// Maybe show a congratulation modal
alert("ยินดีด้วย! คุณเรียนจบหลักสูตรแล้ว")
}
}
// Reload course data to ensure everything is synced (optional but safer)
// await loadCourseData()
} }
} }
@ -325,10 +408,9 @@ onBeforeUnmount(() => {
<!-- Video Player & Content Area --> <!-- Video Player & Content Area -->
<div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow"> <div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow">
<!-- Video Player --> <!-- Video Player -->
<div v-if="currentLesson" class="bg-black rounded-xl overflow-hidden shadow-lg mb-6 aspect-video relative group"> <div v-if="currentLesson && videoSrc" class="bg-black rounded-xl overflow-hidden shadow-lg mb-6 aspect-video relative group">
<video <video
ref="videoRef" ref="videoRef"
v-if="videoSrc"
:src="videoSrc" :src="videoSrc"
class="w-full h-full object-contain" class="w-full h-full object-contain"
@click="togglePlay" @click="togglePlay"
@ -336,15 +418,9 @@ onBeforeUnmount(() => {
@loadedmetadata="onVideoMetadataLoaded" @loadedmetadata="onVideoMetadataLoaded"
@ended="onVideoEnded" @ended="onVideoEnded"
/> />
<div v-else class="flex items-center justify-center h-full text-white/50 bg-slate-900">
<div class="text-center">
<q-icon name="article" size="xl" />
<p class="mt-2">{{ $t('classroom.readingMaterial') }}</p>
</div>
</div>
<!-- Custom Controls Overlay (Simplified) --> <!-- Custom Controls Overlay (Simplified) -->
<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" v-if="videoSrc"> <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"> <div class="flex items-center gap-4 text-white">
<q-btn flat round dense :icon="isPlaying ? 'pause' : 'play_arrow'" @click.stop="togglePlay" /> <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="relative flex-grow h-1 bg-white/30 rounded cursor-pointer" @click="seek">
@ -363,9 +439,40 @@ onBeforeUnmount(() => {
<p class="text-slate-700 dark:text-slate-300 text-base md:text-lg" v-if="currentLesson.description">{{ getLocalizedText(currentLesson.description) }}</p> <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 -->
<div v-if="!videoSrc && 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)]"> <!-- 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-html="getLocalizedText(currentLesson.content)" class="text-base md:text-lg leading-relaxed text-slate-900 dark:text-slate-200"></div> <div v-html="getLocalizedText(currentLesson.content)" class="text-base md:text-lg leading-relaxed text-slate-900 dark:text-slate-200"></div>
</div> </div>
<!-- Attachments Section -->
<div v-if="currentLesson.attachments && currentLesson.attachments.length > 0" class="mt-8">
<h3 class="text-lg font-bold mb-4 text-slate-900 dark:text-white flex items-center gap-2">
<q-icon name="attach_file" />
{{ $t('classroom.attachments') || 'เอกสารประกอบ' }}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<a
v-for="file in currentLesson.attachments"
:key="file.file_name"
:href="file.presigned_url"
target="_blank"
class="flex items-center gap-4 p-4 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors group"
>
<div class="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-600 flex items-center justify-center">
<q-icon name="picture_as_pdf" size="24px" />
</div>
<div class="flex-1 min-w-0">
<div class="font-bold text-slate-900 dark:text-slate-200 truncate group-hover:text-blue-600 transition-colors">
{{ file.file_name }}
</div>
<div class="text-xs text-slate-500 dark:text-slate-400">
{{ (file.file_size / 1024 / 1024).toFixed(2) }} MB
</div>
</div>
<q-icon name="download" class="text-slate-400 group-hover:text-blue-600" />
</a>
</div>
</div>
</div> </div>
</div> </div>
</q-page> </q-page>

View file

@ -49,7 +49,7 @@ const loadEnrolledCourses = async () => {
: 'IN_PROGRESS' : 'IN_PROGRESS'
const res = await fetchEnrolledCourses({ const res = await fetchEnrolledCourses({
status: activeFilter.value === 'all' ? undefined : (activeFilter.value === 'completed' ? 'COMPLETED' : 'IN_PROGRESS') status: apiStatus
}) })
if (res.success) { if (res.success) {

View file

@ -268,7 +268,7 @@ onMounted(() => {
size="128" size="128"
class="border-4 border-white dark:border-[#1e293b] shadow-2xl bg-slate-800" class="border-4 border-white dark:border-[#1e293b] shadow-2xl bg-slate-800"
/> />
<div class="absolute bottom-2 right-2 bg-emerald-500 w-5 h-5 rounded-full border-4 border-white dark:border-[#1e293b]"/>
</div> </div>
<div class="pb-2"> <div class="pb-2">
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-1">{{ userData.firstName }} {{ userData.lastName }}</h2> <h2 class="text-3xl font-black text-slate-900 dark:text-white mb-1">{{ userData.firstName }} {{ userData.lastName }}</h2>