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 สำหรับเช็คว่ากำลังโหลดโปรไฟล์อยู่หรือไม่ เพื่อป้องกันการยิงซ้อน
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) => {

View file

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

View file

@ -18,7 +18,7 @@ useHead({
const route = useRoute()
const { t } = useI18n()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, markLessonComplete, checkLessonAccess } = useCourse()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, markLessonComplete, checkLessonAccess, fetchVideoProgress } = useCourse()
// State
const sidebarOpen = ref(false)
@ -110,22 +110,61 @@ const loadLesson = async (lessonId: number) => {
// Optional: Check access first
const accessRes = await checkLessonAccess(courseId.value, lessonId)
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
return
}
// 1. Fetch content
const res = await fetchLessonContent(courseId.value, lessonId)
if (res.success) {
currentLesson.value = res.data
// Restore progress if available
if (res.progress) {
// Wait for video metadata to load usually, but set state
// We might set currentTime once metadata loaded
if (res.progress.video_progress_seconds > 0) {
currentTime.value = res.progress.video_progress_seconds
// We will apply this to videoRef locally when it is ready
// 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
}
}
}
}
}
}
@ -164,9 +203,11 @@ const updateProgress = () => {
const videoSrc = computed(() => {
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)
// 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(' ')) {
return content
}
@ -177,16 +218,29 @@ const videoSrc = computed(() => {
// 3. (Progress Tracking)
// ==========================================
// 10
// 10
watch(() => isPlaying.value, (playing) => {
if (playing) {
saveProgressInterval.value = setInterval(() => {
saveProgressInterval.value = setInterval(async () => {
if (videoRef.value && currentLesson.value) {
// Send integers to avoid potential backend float issues
saveVideoProgress(
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 {
@ -202,15 +256,44 @@ watch(() => isPlaying.value, (playing) => {
}
})
// (Complete)
// (Complete)
const onVideoEnded = async () => {
isPlaying.value = false
if (currentLesson.value) {
await markLessonComplete(courseId.value, currentLesson.value.id)
//
await loadCourseData()
const res = await markLessonComplete(courseId.value, currentLesson.value.id)
// 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 -->
<div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow">
<!-- 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
ref="videoRef"
v-if="videoSrc"
:src="videoSrc"
class="w-full h-full object-contain"
@click="togglePlay"
@ -336,15 +418,9 @@ onBeforeUnmount(() => {
@loadedmetadata="onVideoMetadataLoaded"
@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) -->
<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">
<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">
@ -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>
<!-- 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>
<!-- 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>
</q-page>

View file

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

View file

@ -268,7 +268,7 @@ onMounted(() => {
size="128"
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 class="pb-2">
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-1">{{ userData.firstName }} {{ userData.lastName }}</h2>