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 สำหรับเช็คว่ากำลังโหลดโปรไฟล์อยู่หรือไม่ เพื่อป้องกันการยิงซ้อน
|
// 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) => {
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,8 @@
|
||||||
"notAvailable": "บทเรียนนี้ยังไม่เปิดให้เข้าชม",
|
"notAvailable": "บทเรียนนี้ยังไม่เปิดให้เข้าชม",
|
||||||
"loadingTitle": "กำลังโหลด...",
|
"loadingTitle": "กำลังโหลด...",
|
||||||
"chapter": "บทที่",
|
"chapter": "บทที่",
|
||||||
"lessons": "บทเรียน"
|
"lessons": "บทเรียน",
|
||||||
|
"attachments": "เอกสารประกอบ"
|
||||||
},
|
},
|
||||||
"quiz": {
|
"quiz": {
|
||||||
"exitTitle": "ออกจากแบบทดสอบ",
|
"exitTitle": "ออกจากแบบทดสอบ",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue