feat: Implement core e-learning classroom interface, user dashboard pages, authentication composable, and Thai localization.
This commit is contained in:
parent
85d7c5c913
commit
9232b6a21d
5 changed files with 140 additions and 31 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -155,7 +155,8 @@
|
|||
"notAvailable": "บทเรียนนี้ยังไม่เปิดให้เข้าชม",
|
||||
"loadingTitle": "กำลังโหลด...",
|
||||
"chapter": "บทที่",
|
||||
"lessons": "บทเรียน"
|
||||
"lessons": "บทเรียน",
|
||||
"attachments": "เอกสารประกอบ"
|
||||
},
|
||||
"quiz": {
|
||||
"exitTitle": "ออกจากแบบทดสอบ",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue