feat: Implement initial e-learning platform frontend structure including dashboard, course management, authentication, and common UI components.

This commit is contained in:
supalerk-ar66 2026-02-27 10:05:33 +07:00
parent aceeb80d9a
commit ad11c6b7c5
44 changed files with 720 additions and 578 deletions

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file AnnouncementModal.vue
* @description Modal component to display course announcements
* @description คอมโพเนนต์ Modal สำหรับแสดงประกาศของคอร์สเรียน (Modal component to display course announcements)
*/
const props = defineProps<{
@ -15,7 +15,7 @@ const emit = defineEmits<{
const { locale, t } = useI18n()
// Helper for localization
// ตัวช่วยจัดการข้อความหลายภาษา (Helper for localization)
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
@ -49,7 +49,7 @@ const getLocalizedText = (text: any) => {
class="p-5 rounded-2xl bg-white dark:bg-slate-800 shadow-sm border border-gray-200 dark:border-white/5 transition-all hover:shadow-md relative overflow-hidden group"
:class="{'ring-2 ring-orange-200 dark:ring-orange-900/40 !bg-orange-50/50 dark:!bg-orange-900/20': ann.is_pinned}"
>
<!-- Pinned Banner -->
<!-- ป้ายกำกับสำหรับข้อความที่ปักหมุดไว้ (Pinned Banner) -->
<div v-if="ann.is_pinned" class="absolute top-0 right-0 p-3">
<q-icon name="push_pin" color="orange" size="18px" class="transform rotate-45" />
</div>

View file

@ -1,12 +1,12 @@
<script setup lang="ts">
/**
* @file CurriculumSidebar.vue
* @description Sidebar Component for displaying course curriculum (Chapters & Lessons)
* Handles lesson navigation, locked status display, and unread announcement badge.
* @description คอมโพเนนต์แถบเมนูด้านข้างสำหรับแสดงหลักสูตรของคอร์สเรียน (บทเรียน & ตอนต่างๆ)
* จัดการการนำทางไปยังบทเรียน, แสดงสถานะการล็อค, และแจ้งเตือนประกาศที่ยังไม่ได้อ่าน
*/
const props = defineProps<{
modelValue: boolean; // Sidebar open state (v-model)
modelValue: boolean; // สถานะการเปิด/ปิดของ Sidebar (Sidebar open state - v-model)
courseData: any;
currentLessonId?: number;
isLoading: boolean;
@ -21,10 +21,10 @@ const emit = defineEmits<{
const { locale } = useI18n()
// State for expansion items
// สถานะของส่วนที่ขยายได้ (State for expansion items)
const chapterOpenState = ref<Record<string, boolean>>({})
// Helper for localization
// ตัวช่วยจัดการข้อความหลายภาษา (Helper for localization)
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
@ -34,13 +34,13 @@ const getLocalizedText = (text: any) => {
return text[currentLocale] || text.th || text.en || ''
}
// Helper: Check if lesson is completed
// ตัวช่วยตรวจสอบว่าบทเรียนเรียนจบหรือยัง (Helper: Check if lesson is completed)
const isLessonCompleted = (lesson: any) => {
return lesson.is_completed === true || lesson.progress?.is_completed === true
}
// Reactive Chapter Completion Status
// Computes a map of chapterId -> boolean (true if all lessons are completed)
// ตรวจสอบสถานะการสำเร็จของบท (Reactive Chapter Completion Status)
// คำนวณเป็น Map ของ chapterId -> boolean (true ถ้าทุกบทเรียนย่อยเรียนจบแล้ว)
const chapterCompletionStatus = computed(() => {
const status: Record<string, boolean> = {}
if (!props.courseData || !props.courseData.chapters) return status
@ -55,7 +55,7 @@ const chapterCompletionStatus = computed(() => {
return status
})
// Local Progress Calculation
// คำนวณความคืบหน้าแบบ Local (Local Progress Calculation)
const progressPercentage = computed(() => {
if (!props.courseData || !props.courseData.chapters) return 0
let total = 0
@ -69,7 +69,7 @@ const progressPercentage = computed(() => {
return total > 0 ? Math.round((completed / total) * 100) : 0
})
// Auto-expand chapter containing current lesson
// ขยายบทเรียนปัจจุบันอัตโนมัติ (Auto-expand chapter containing current lesson)
watch(() => props.currentLessonId, (newId) => {
if (newId && props.courseData?.chapters) {
props.courseData.chapters.forEach((chapter: any) => {
@ -81,7 +81,7 @@ watch(() => props.currentLessonId, (newId) => {
}
}, { immediate: true })
// Initialize all chapters as open by default on load
// ตั้งค่าเริ่มต้นให้ทุกบทเรียนเปิดอยู่เมื่อโหลด (Initialize all chapters as open by default on load)
watch(() => props.courseData, (newData) => {
if (newData?.chapters) {
newData.chapters.forEach((chapter: any) => {
@ -104,10 +104,10 @@ watch(() => props.courseData, (newData) => {
:breakpoint="1024"
class="bg-slate-50 dark:!bg-slate-900 shadow-xl"
>
<!-- Main Container: Enforce Column Layout and Full Width -->
<!-- คอนเทนเนอร์หลักบังคับใช้ความกว้างเต็มที่ (Main Container: Enforce Column Layout and Full Width) -->
<div v-if="courseData" class="flex flex-col w-full h-full overflow-hidden text-slate-900 dark:!text-white relative bg-slate-50 dark:!bg-slate-900">
<!-- 1. Header Section (Fixed at Top) -->
<!-- 1. ส่วนหัว ด้านบนคงที่ (Header Section - Fixed at Top) -->
<div class="flex-none p-5 border-b border-slate-200 dark:border-white/10 bg-white dark:!bg-slate-900 z-10 w-full">
<h2 class="text-sm font-bold mb-4 line-clamp-2 leading-snug block w-full text-slate-900 dark:!text-white">{{ getLocalizedText(courseData.course.title) }}</h2>
@ -123,11 +123,11 @@ watch(() => props.courseData, (newData) => {
</div>
</div>
<!-- 2. Curriculum List (Scrollable Area) -->
<!-- 2. รายการหลักสูตร พื้นที่เลื่อนได้ (Curriculum List - Scrollable Area) -->
<div class="flex-1 overflow-y-auto bg-slate-50 dark:!bg-slate-900 w-full p-4 space-y-3">
<q-list class="block w-full">
<div v-for="(chapter, idx) in courseData.chapters" :key="chapter.id" class="block w-full mb-3">
<!-- Chapter Accordion -->
<!-- กล่องข้อมูลของบท (Chapter Accordion) -->
<q-expansion-item
v-model="chapterOpenState[chapter.id]"
class="bg-white dark:!bg-slate-800 rounded-xl overflow-hidden shadow-sm border border-slate-200 dark:border-slate-700 w-full"
@ -137,7 +137,7 @@ watch(() => props.courseData, (newData) => {
<template v-slot:header>
<div class="flex items-center w-full py-3 text-slate-900 dark:!text-white">
<div class="mr-3 flex-shrink-0">
<!-- Chapter Indicator (Check or Number) -->
<!-- ตัวบ่งชี้บทเรียน เครื่องหมายถูกหรือตัวเลข (Chapter Indicator - Check or Number) -->
<div class="w-7 h-7 rounded-full border-2 flex items-center justify-center transition-colors font-bold"
:class="chapterCompletionStatus[chapter.id]
? 'border-green-500 text-green-500 bg-green-50 dark:!bg-green-500/10'
@ -146,7 +146,7 @@ watch(() => props.courseData, (newData) => {
<span v-else class="text-[10px]">{{ Number(idx) + 1 }}</span>
</div>
</div>
<!-- Explicitly handle text overflow -->
<!-- จัดการตัวอักษรที่ล้นเกินอย่างชัดเจน (Explicitly handle text overflow) -->
<div class="flex-1 min-w-0 pr-2 overflow-hidden">
<div class="font-bold text-sm leading-tight mb-0.5 truncate block w-full">{{ getLocalizedText(chapter.title) }}</div>
<div class="text-[10px] text-slate-500 dark:!text-slate-400 font-normal truncate block w-full">
@ -156,7 +156,7 @@ watch(() => props.courseData, (newData) => {
</div>
</template>
<!-- Lessons List -->
<!-- รายการบทเรียนย่อย (Lessons List) -->
<div class="bg-slate-50 dark:!bg-slate-800/50 border-t border-slate-100 dark:border-slate-700 w-full">
<div
v-for="(lesson, lIdx) in chapter.lessons"
@ -167,27 +167,27 @@ watch(() => props.courseData, (newData) => {
: 'border-transparent'"
@click="!lesson.is_locked && emit('select-lesson', lesson.id)"
>
<!-- Lesson Status Icon -->
<!-- ไอคอนสถานะของบทเรียน (Lesson Status Icon) -->
<div class="mr-3 flex-shrink-0">
<!-- Completed (Takes Precedence) -->
<!-- เรียนจบแล้ว (สำคัญที่สุด) (Completed - Takes Precedence) -->
<q-icon v-if="isLessonCompleted(lesson)"
name="check_circle"
class="text-green-500"
size="20px"
/>
<!-- Active/Playing (If not completed) -->
<!-- กำลังเรียนอยู่ (Active/Playing - If not completed) -->
<q-icon v-else-if="currentLessonId === lesson.id"
name="play_circle_filled"
class="text-blue-600 dark:!text-blue-400 animate-pulse"
size="20px"
/>
<!-- Locked -->
<!-- ถูกล็อคอยู่ (Locked) -->
<q-icon v-else-if="lesson.is_locked"
name="lock"
class="text-slate-400 dark:!text-slate-500 opacity-70"
size="18px"
/>
<!-- Not Started -->
<!-- ยังไม่ได้เริ่ม (Not Started) -->
<div v-else class="w-[18px] h-[18px] rounded-full border-2 border-slate-300 dark:border-slate-600"></div>
</div>
@ -214,7 +214,7 @@ watch(() => props.courseData, (newData) => {
</template>
<style scoped>
/* Custom scrollbar for better aesthetics */
/* สครอลบาร์ปรับแต่งเพื่อความสวยงาม (Custom scrollbar for better aesthetics) */
::-webkit-scrollbar {
width: 4px;
}

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file VideoPlayer.vue
* @description Video Player Component with custom controls provided by design
* @description คอมโพเนนต์เครื่องเล่นวิดีโอพร้อมด้วยตัวควบคุมแบบกำหนดเองตามการออกแบบ (Video Player Component with custom controls provided by design)
*/
const props = defineProps<{
@ -22,7 +22,7 @@ const videoProgress = ref(0);
const currentTime = ref(0);
const duration = ref(0);
// Media Prefs
// การตั้งค่าเกี่ยวกับสื่อ (Media Prefs)
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs();
const volumeIcon = computed(() => {
@ -40,7 +40,7 @@ const formatTime = (time: number) => {
const currentTimeDisplay = computed(() => formatTime(currentTime.value));
const durationDisplay = computed(() => formatTime(duration.value || 0));
// YouTube Helper Logic
// ตรรกะตัวช่วยสำหรับ YouTube (YouTube Helper Logic)
const isYoutube = computed(() => {
const s = props.src.toLowerCase();
return s.includes('youtube.com') || s.includes('youtu.be');
@ -50,7 +50,7 @@ const youtubeEmbedUrl = computed(() => {
if (!isYoutube.value) return '';
let videoId = '';
// Extract Video ID
// ดึงรหัสวิดีโอ (Extract Video ID)
if (props.src.includes('youtu.be')) {
videoId = props.src.split('youtu.be/')[1]?.split('?')[0];
} else {
@ -58,18 +58,18 @@ const youtubeEmbedUrl = computed(() => {
videoId = urlParams.get('v') || '';
}
// Return Embed URL with enablejsapi=1
// คืนค่า URL สำหรับฝังพร้อมเปิดใช้งาน jsapi (Return Embed URL with enablejsapi=1)
return `https://www.youtube.com/embed/${videoId}?enablejsapi=1&rel=0`;
});
// YouTube API Tracking
// การติดตามผ่าน YouTube API (YouTube API Tracking)
let ytPlayer: any = null;
let ytInterval: any = null;
const initYoutubeAPI = () => {
if (!isYoutube.value || typeof window === 'undefined') return;
// Load API Script if not exists
// โหลดสคริปต์ API หากยังไม่มี (Load API Script if not exists)
if (!(window as any).YT) {
const tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
@ -83,7 +83,7 @@ const initYoutubeAPI = () => {
'onReady': (event: any) => {
duration.value = event.target.getDuration();
// Resume Logic for YouTube
// ตรรกะการเล่นต่อสำหรับ YouTube (Resume Logic for YouTube)
if (props.initialSeekTime && props.initialSeekTime > 0) {
event.target.seekTo(props.initialSeekTime, true);
}
@ -118,7 +118,7 @@ const startYTTracking = () => {
currentTime.value = ytPlayer.getCurrentTime();
emit('timeupdate', currentTime.value, duration.value);
}
}, 1000); // Check every second
}, 1000); // ตรวจสอบทุกๆ วินาที (Check every second)
};
const stopYTTracking = () => {
@ -145,7 +145,7 @@ onUnmounted(() => {
destroyYoutubePlayer();
});
// Watch for src change to re-init
// เฝ้าดูการเปลี่ยนแปลงของ src เพื่อเริ่มต้นใหม่ (Watch for src change to re-init)
watch(() => props.src, (newSrc, oldSrc) => {
if (newSrc !== oldSrc) {
destroyYoutubePlayer();
@ -174,8 +174,8 @@ const togglePlay = () => {
playPromise.then(() => {
isPlaying.value = true;
}).catch(error => {
// Auto-play was prevented or play was interrupted
// We can safely ignore this error
// การเล่นอัตโนมัติถูกป้องกันหรือถูกขัดจังหวะ (Auto-play was prevented or play was interrupted)
// สามารถละเว้นข้อผิดพลาดนี้ได้อย่างปลอดภัย (We can safely ignore this error)
console.log("Video play request handled:", error.name);
});
}
@ -223,14 +223,14 @@ const handleVolumeChange = (val: any) => {
setVolume(newVol);
};
// Expose video ref for parent to control if needed
// เปิดเผย video ref ให้คอมโพเนนต์แม่ควบคุมถ้าจำเป็น (Expose video ref for parent to control if needed)
defineExpose({
videoRef,
pause: () => videoRef.value?.pause(),
currentTime: () => videoRef.value?.currentTime || 0
});
// Watch for volume/mute changes to apply to video element
// เฝ้าดูการเปลี่ยนแปลงระดับเสียง/ปิดเสียง เพื่อนำไปใช้กับองค์ประกอบวิดีโอ (Watch for volume/mute changes to apply to video element)
watch([volume, isMuted], () => {
if (videoRef.value) applyTo(videoRef.value);
});
@ -238,7 +238,7 @@ watch([volume, isMuted], () => {
<template>
<div class="bg-black rounded-xl overflow-hidden shadow-2xl mb-6 aspect-video relative group ring-1 ring-white/10">
<!-- 1. YouTube Player -->
<!-- 1. เครื่องเล่น YouTube (YouTube Player) -->
<iframe
v-if="isYoutube"
id="youtube-iframe"
@ -249,7 +249,7 @@ watch([volume, isMuted], () => {
allowfullscreen
></iframe>
<!-- 2. Standard HTML5 Video Player -->
<!-- 2. เครื่องเล่นวิดีโอ HTML5 มาตรฐาน (Standard HTML5 Video Player) -->
<div v-else class="w-full h-full relative group/video cursor-pointer">
<video
ref="videoRef"
@ -262,9 +262,9 @@ watch([volume, isMuted], () => {
@ended="handleEnded"
/>
<!-- Custom Controls Overlay (Only for HTML5 Video) -->
<!-- เลเยอร์ควบคุมแบบกำหนดเอง (Overlay) เฉพาะสำหรับวิดีโอ HTML5 เท่านั้น (Custom Controls Overlay (Only for HTML5 Video)) -->
<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 -->
<!-- แถบแสดงความคืบหน้า (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>
@ -275,7 +275,7 @@ watch([volume, isMuted], () => {
<div class="flex-grow"></div>
<!-- Volume Control -->
<!-- ตัวควบคุมระดับเสียง (Volume Control) -->
<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">

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
/**
* @file FormInput.vue
* @description Reusable input component with label, error handling, and support for disabled/required states.
* Now supports password visibility toggle.
* @description คอมโพเนนต์ช่องกรอกข้อมูล (Input) แบบนำกลับมาใช้ใหม่ได้ พร้อมรองรับข้อความป้ายกำกับ, จัดการข้อผิดพลาด และสถานะปิดใช้งาน/บังคับกรอก
* รองรับการสลับซ่อน/แสดงรหัสผ่าน
*/
const props = defineProps<{
@ -16,19 +16,19 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
/** Update v-model value */
/** อัปเดตค่า v-model (Update v-model value) */
'update:modelValue': [value: string]
}>()
// Password visibility state
// สถานะการซ่อน/แสดงรหัสผ่าน (Password visibility state)
const showPassword = ref(false)
// Toggle function
// ฟังก์ชันสำหรับสลับสถานะ (Toggle function)
const togglePassword = () => {
showPassword.value = !showPassword.value
}
// Compute input type based on visibility state
// คำนวณประเภทของช่องกรอกข้อมูล بناءً pada state (Compute input type based on visibility state)
const inputType = computed(() => {
if (props.type === 'password') {
return showPassword.value ? 'text' : 'password'
@ -59,7 +59,7 @@ const updateValue = (event: Event) => {
@input="updateValue"
>
<!-- Password Toggle Button -->
<!-- ปุ่มสลับซ่อน/แสดงรหัสผ่าน (Password Toggle Button) -->
<button
v-if="type === 'password'"
type="button"
@ -67,13 +67,13 @@ const updateValue = (event: Event) => {
@click="togglePassword"
tabindex="-1"
>
<!-- Eye Icon (Show) -->
<!-- ไอคอนเปิดตา (แสดงรหัสผ่าน) (Eye Icon - Show) -->
<svg v-if="!showPassword" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<!-- Eye Off Icon (Hide) -->
<!-- ไอคอนปิดตา (ซ่อนรหัสผ่าน) (Eye Off Icon - Hide) -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>

View file

@ -1,20 +1,20 @@
<script setup lang="ts">
/**
* @file GlobalLoader.vue
* @description Global full-screen loading overlay that triggers during page navigation.
* Uses a premium pulsing logo animation.
* @description คอมโพเนนต์หน้าจอโหลดแบบเต็มจอ (Global full-screen loading) แสดงผลตอนเปลี่ยนหน้า
* พร้มแอนิเมชันโลโก้ขยับได้แบบพรีเมียม
*/
const nuxtApp = useNuxtApp()
const isLoading = ref(false)
// Hook into Nuxt page transitions
// ดักจับจังหวะการเปลี่ยนหน้าผ่าย Nuxt hook (Hook into Nuxt page transitions)
nuxtApp.hook('page:start', () => {
isLoading.value = true
})
nuxtApp.hook('page:finish', () => {
// Add a small delay for better UX (prevents flickering on fast loads)
// หน่วงเวลาเล็กน้อยเพื่อให้ไหลลื่น ไม่กระพริบเร็วไปหากหน้าโหลดเสร็จไว (Add a small delay for better UX)
setTimeout(() => {
isLoading.value = false
}, 500)
@ -25,14 +25,14 @@ nuxtApp.hook('page:finish', () => {
<Transition name="fade">
<div v-if="isLoading" class="fixed inset-0 z-[99999] flex flex-col items-center justify-center bg-white dark:bg-[#0f172a] transition-colors duration-300">
<div class="relative flex flex-col items-center">
<!-- Main Logo Box -->
<!-- กล่องโลโก้หลัก (Main Logo Box) -->
<div class="w-20 h-20 bg-blue-50 dark:bg-blue-900/20 rounded-2xl flex items-center justify-center mb-6 animate-pulse-soft">
<div class="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-600/30 animate-bounce-subtle">
<span class="text-2xl font-black text-white">E</span>
</div>
</div>
<!-- Loading Text -->
<!-- ข้อความระหว่างโหลด (Loading Text) -->
<div class="flex flex-col items-center gap-2">
<h3 class="text-lg font-bold text-slate-800 dark:text-white tracking-wide">e-Learning</h3>
<div class="flex gap-1">

View file

@ -1,13 +1,13 @@
<script setup lang="ts">
/**
* @file LanguageSwitcher.vue
* @description Language switcher component using Quasar dropdown.
* Allows switching between Thai (th) and English (en) locales.
* @description คอมโพเนนต์ตัวสลับภาษาใช้ปุ่ม Dropdown ของ Quasar
* ใช้สลับระหว่างภาษาไทย (th) และภาษาอังกฤษ (en)
*/
const { locale, setLocale, locales } = useI18n()
// Get available locales with their names
// ดึงรายการภาษาที่มีอยู่พร้อมชื่อภาษา (Get available locales with their names)
const availableLocales = computed(() => {
return (locales.value as Array<{ code: string; name: string }>).map((loc) => ({
code: loc.code,
@ -15,13 +15,13 @@ const availableLocales = computed(() => {
}))
})
// Get flag image path for a locale
// ดึงพาธภาพธงชาติสำหรับภาษานั้นๆ (Get flag image path for a locale)
const getFlagPath = (code: string) => `/flags/${code}.png`
// Handle locale change
// จัดการเมื่อเปลี่ยนภาษา (Handle locale change)
const changeLocale = async (code: string) => {
await setLocale(code as 'th' | 'en')
// Cookie is automatically handled by @nuxtjs/i18n with detectBrowserLanguage.useCookie
// คุกกี้ (Cookie) จะถูกจัดการอัตโนมัติโดย @nuxtjs/i18n จากออปชัน detectBrowserLanguage.useCookie
}
</script>
@ -32,7 +32,7 @@ const changeLocale = async (code: string) => {
class="language-btn"
:aria-label="$t('language.label')"
>
<!-- Show current locale flag -->
<!-- แสดงธงชาติตามภาษาที่ใช้อยู่ (Show current locale flag) -->
<img
:src="getFlagPath(locale)"
:alt="locale.toUpperCase()"
@ -178,7 +178,7 @@ const changeLocale = async (code: string) => {
</style>
<style>
/* Global styles for teleported menu */
/* สไตล์ Global สำหรับเมนูที่ถูกข้ามไปแสดงผลที่อื่นด้วย Teleport (Global styles for teleported menu) */
.language-menu {
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);

View file

@ -1,4 +1,8 @@
<script setup lang="ts">
/**
* @file LoadingSkeleton.vue
* @description คอมโพเนนต์ Skeleton สำหรับแสดงโครงร่างหน้าจอระหว่างรอโหลดข้อมูล (Loading Skeleton Component)
*/
defineProps<{
type?: 'text' | 'avatar' | 'card' | 'button'
width?: string
@ -9,6 +13,7 @@ defineProps<{
<template>
<div class="skeleton-wrapper">
<!-- กรณีเป็นโครงร่างประเภทการ์ด (Card type skeleton) -->
<template v-if="type === 'card'">
<div v-for="i in (count || 1)" :key="i" class="skeleton-card">
<div class="skeleton skeleton-image"/>
@ -20,14 +25,17 @@ defineProps<{
</div>
</template>
<!-- กรณีเป็นโครงร่างประเภทรูปโปรไฟล์ (Avatar type skeleton) -->
<template v-else-if="type === 'avatar'">
<div class="skeleton skeleton-avatar"/>
</template>
<!-- กรณีเป็นโครงร่างประเภทปุ่มกด (Button type skeleton) -->
<template v-else-if="type === 'button'">
<div class="skeleton skeleton-button" :style="{ width: width || '120px' }"/>
</template>
<!-- กรณีอื่นๆ จะแสดงเป็นบรรทัดข้อความ (Fallback/Text type skeleton) -->
<template v-else>
<div
v-for="i in (count || 1)"

View file

@ -1,4 +1,8 @@
<script setup lang="ts">
/**
* @file LoadingSpinner.vue
* @description ไอคอนหมุนแสดงการโหลด (Loading Spinner Component) เหมาะสำหรับใช้ตรงจุดเล็กๆ หรือตอนโหลดหน้าเว็บ
*/
defineProps<{
size?: 'sm' | 'md' | 'lg'
text?: string

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
/**
* @file CourseCard.vue
* @description Standardized Course Card Component.
* Usage: <CourseCard :id="1" title="..." ... />
* @description คอมโพเนนต์การ์ดแสดงคอร์สเรียนมาตรฐาน (Standardized Course Card Component)
* วิธีใช้งาน: <CourseCard :id="1" title="..." ... />
*/
interface CourseCardProps {
@ -20,7 +20,7 @@ interface CourseCardProps {
image?: string
loading?: boolean
// Action Flags
// ตัวควบคุมการแสดงปุ่มต่างๆ (Action Flags)
showViewDetails?: boolean
showContinue?: boolean
showCertificate?: boolean
@ -59,7 +59,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
<template>
<div class="group relative flex flex-col bg-white dark:!bg-slate-900 rounded-3xl overflow-hidden border border-slate-200 dark:border-white/5 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full">
<!-- Thumbnail Section -->
<!-- ส่วนรูปหน้าปก (Thumbnail Section) -->
<div class="relative w-full aspect-video overflow-hidden">
<img
v-if="image"
@ -71,12 +71,12 @@ const displayCategory = computed(() => getLocalizedText(props.category))
<q-icon name="image" size="48px" class="text-slate-300 dark:text-slate-600" />
</div>
<!-- Overlays -->
<!-- เลเยอร์ฟิลเตอร์ซ้อนทับ (Overlays) -->
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<!-- Completed Badge -->
<!-- ป้ายแสดงสถานะเรียนจบ (Completed Badge) -->
<div v-if="completed" class="absolute inset-0 bg-emerald-900/40 backdrop-blur-[2px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span class="bg-emerald-500 text-white w-12 h-12 rounded-full flex items-center justify-center shadow-lg transform scale-75 group-hover:scale-100 transition-transform">
<q-icon name="check" size="24px" />
@ -84,9 +84,9 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div>
</div>
<!-- Content Section -->
<!-- ส่วนเนื้อหาข้อมูล (Content Section) -->
<div class="p-6 flex flex-col flex-grow">
<!-- Meta Info (Lessons/Duration) -->
<!-- ข้อมูลประกอบย่อย เช่น บทเรียน/ระยะเวลา (Meta Info - Lessons/Duration) -->
<div class="flex items-center gap-3 text-xs font-bold text-slate-500 dark:text-slate-400 mb-3 uppercase tracking-wider">
<span v-if="lessons" class="flex items-center gap-1">
<q-icon name="menu_book" size="14px" /> {{ lessons }} {{ $t('course.lessonsUnit') }}
@ -96,18 +96,18 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</span>
</div>
<!-- Title -->
<!-- ชื่อคอร์ส (Title) -->
<h3 class="text-lg font-black text-slate-900 dark:text-white mb-2 line-clamp-2 leading-tight group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{{ displayTitle }}
</h3>
<!-- Description -->
<!-- รายละเอียดเพิ่มเติม (Description) -->
<p v-if="displayDescription" class="text-sm text-slate-500 dark:text-slate-400 line-clamp-2 mb-6">
{{ displayDescription }}
</p>
<div class="mt-auto pt-4">
<!-- Progress Bar -->
<!-- หลอดความคืบหน้า (Progress Bar) -->
<div v-if="progress !== undefined && !completed && !hideProgress" class="mb-4">
<div class="flex justify-between text-[10px] font-bold uppercase mb-1">
<span class="text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
@ -118,9 +118,9 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div>
</div>
<!-- Action Buttons -->
<!-- ปุ่มปฏิบัติการต่างๆ (Action Buttons) -->
<div v-if="!hideActions" class="flex flex-col gap-3">
<!-- View Details (Secondary Action) -->
<!-- ปุ่มดูรายละเอียด (ปุ่มรอง) (View Details - Secondary Action) -->
<q-btn
v-if="showViewDetails && !completed && !progress"
flat
@ -130,7 +130,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
:to="`/course/${id}`"
/>
<!-- Continue Learning (Primary Action) -->
<!-- ปุ่มเรียนต่อ/เริ่มเรียน (ปุ่มหลัก) (Continue Learning - Primary Action) -->
<q-btn
v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)"
unelevated
@ -142,7 +142,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div>
<div v-if="completed" class="space-y-2">
<!-- Study Again -->
<!-- ปุ่มเรียนอีกครั้ง (Study Again) -->
<q-btn
v-if="showStudyAgain"
unelevated
@ -152,7 +152,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
:to="`/classroom/learning?course_id=${id}`"
/>
<!-- Download Certificate -->
<!-- ปุ่มดาวน์โหลดใบรับรอง (Download Certificate) -->
<q-btn
v-if="showCertificate"
unelevated
@ -168,5 +168,5 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</template>
<style scoped>
/* Scoped overrides if needed */
/* ใส่โค้ด CSS เพิ่มได้ถ้าต้องการครอบคลุมเฉพาะไฟล์นี้ (Scoped overrides if needed) */
</style>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file CategorySidebar.vue
* @description Sidebar for filtering courses by category
* @description แถบเมนูด้านข้างสำหรับกรองคอร์สตามหมวดหมู่ (Sidebar for filtering courses by category)
*/
const props = defineProps<{
@ -81,13 +81,13 @@ const toggleCategory = (id: number) => {
{{ getLocalizedText(cat.name) }}
</span>
<!-- Active Indicator Dot -->
<!-- จุดแสดงสถานะเมื่อถูกเลือก (Active Indicator Dot) -->
<div v-if="modelValue.includes(cat.id)" class="ml-auto w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400 shadow-lg shadow-blue-500/50"></div>
</div>
</div>
</div>
<!-- Show More/Less Action -->
<!-- ปุ่มแสดงเพิ่มเติม/แสดงน้อยลง (Show More/Less Action) -->
<div
v-if="categories.length > 5"
@click="showAllCategories = !showAllCategories"

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file CourseDetailView.vue
* @description Quick view of course details including video preview, curriculum, and enroll logic
* @description แสดงรายละเอียดคอร์สแบบรวดเร็ว รวมถึงตัวอย่างวิดีโอ, หลักสูตร, และระบบการลงทะเบียน
*/
import { ref, computed } from 'vue'
@ -51,9 +51,9 @@ const handleEnroll = () => {
if(!props.course) return;
enrollmentLoading.value = true;
emit('enroll', props.course.id);
// Loading state reset depends on parent, but locally we can reset after emit or keep until prop changes
// In this pattern, we just emit.
setTimeout(() => enrollmentLoading.value = false, 2000); // Safety timeout
// สถานะ Loading ถูกจัดการโดยคอมโพเนนต์แม่ แต่ในคอมโพเนนต์นี้เรารีเซ็ตหลังจากส่ง event หรือเก็บไว้จนกว่า prop จะเปลี่ยน
// ในรูปแบบนี้ เราแค่ส่ง event ออกไป (just emit)
setTimeout(() => enrollmentLoading.value = false, 2000); // ตั้งเวลารีเซ็ตเพื่อความปลอดภัย (Safety timeout)
};
const instructorData = computed(() => {
if (props.course?.instructors && props.course.instructors.length > 0) {
@ -67,10 +67,10 @@ const instructorData = computed(() => {
<template>
<div class="animate-fade-in-up">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Left: Content Detail -->
<!-- ด้านซ้าย: รายละเอียดเนื้อหา (Left: Content Detail) -->
<div class="lg:col-span-2 space-y-8">
<!-- Video Preview Section -->
<!-- ส่วนแสดงตัวอย่างวิดีโอ (Video Preview Section) -->
<div class="relative aspect-video rounded-3xl overflow-hidden shadow-2xl group cursor-pointer bg-slate-900 border-4 border-white dark:border-slate-800 transition-transform duration-500 hover:scale-[1.01]">
<template v-if="course.media?.video_url">
<video
@ -81,19 +81,19 @@ const instructorData = computed(() => {
<source :src="course.media.video_url" type="video/mp4">
{{ $t('course.videoNotSupported') }}
</video>
<!-- Custom Play Overlay when not playing - simple version is often best -->
<!-- ปุ่มเล่นวิดีโอแบบปรับแต่งเองตอนยังไม่เล่น (Custom Play Overlay when not playing) -->
</template>
<!-- Beautiful Image Showcase if no video -->
<!-- แสดงรูปภาพสวยๆ กรณีที่ไม่มีวิดีโอ (Beautiful Image Showcase if no video) -->
<template v-else>
<div class="w-full h-full flex items-center justify-center relative overflow-hidden bg-slate-950 group">
<!-- Blurred background fill -->
<!-- รูปพื้นหลังเบลอ (Blurred background fill) -->
<img
v-if="course.thumbnail_url || course.cover_image"
:src="course.thumbnail_url || course.cover_image"
class="absolute inset-0 w-full h-full object-cover opacity-40 blur-2xl scale-125"
/>
<!-- Main Sharp Image -->
<!-- รูปหลักแบบคมชัด (Main Sharp Image) -->
<img
v-if="course.thumbnail_url || course.cover_image"
:src="course.thumbnail_url || course.cover_image"
@ -107,7 +107,7 @@ const instructorData = computed(() => {
</template>
</div>
<!-- Course Title & Description -->
<!-- ชื่อคอร์สและรายละเอียด (Course Title & Description) -->
<div>
<h1 class="text-3xl md:text-4xl font-extrabold text-slate-900 dark:text-white mb-4 leading-tight">
{{ getLocalizedText(course.title) }}
@ -118,10 +118,10 @@ const instructorData = computed(() => {
</div>
</div>
<!-- Course Detail - Single Page Layout -->
<!-- รายละเอียดคอร์ส - รูแแบบหน้าเดียว (Course Detail - Single Page Layout) -->
<div class="space-y-10">
<!-- Instructor Info -->
<!-- ข้อมูลผู้สอน (Instructor Info) -->
<div class="flex flex-col sm:flex-row gap-6 items-start sm:items-center pb-8 border-b border-slate-200 dark:border-slate-800">
<q-avatar size="64px">
<img :src="instructorData?.profile?.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" />
@ -135,7 +135,7 @@ const instructorData = computed(() => {
</div>
</div>
<!-- Curriculum / Lesson Details -->
<!-- รายละเอียดหลักสูตร / บทเรียน (Curriculum / Lesson Details) -->
<div>
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-slate-900 dark:text-white">
@ -148,7 +148,7 @@ const instructorData = computed(() => {
<div class="space-y-4">
<div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="group">
<!-- Chapter Header -->
<!-- ส่วนหัวของบท (Chapter Header) -->
<div class="px-6 py-4 bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-white/5 font-black text-slate-800 dark:text-white flex justify-between items-center mb-2 shadow-sm">
<span class="flex items-center gap-3">
<span class="w-7 h-7 flex items-center justify-center bg-slate-100 dark:bg-white/10 rounded-lg text-xs font-bold font-mono">{{ Number(idx) + 1 }}</span>
@ -157,7 +157,7 @@ const instructorData = computed(() => {
<span class="text-[10px] uppercase font-black tracking-widest text-slate-400 opacity-60">{{ chapter.lessons?.length || 0 }} {{ $t('course.lessonsUnit') }}</span>
</div>
<!-- Lessons List -->
<!-- รายการบทเรียน (Lessons List) -->
<div class="ml-4 pl-4 border-l-2 border-slate-100 dark:border-slate-800 space-y-1 mt-3">
<div v-for="lesson in chapter.lessons" :key="lesson.id" class="px-5 py-3 flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-white/5 rounded-xl transition-all hover:translate-x-1">
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0" :class="lesson.type === 'VIDEO' ? 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400' : 'bg-orange-50 text-orange-600 dark:bg-orange-500/10 dark:text-orange-400'">
@ -173,7 +173,7 @@ const instructorData = computed(() => {
</div>
</div>
<!-- Empty State -->
<!-- กรณีที่ไม่มีข้อมูล (Empty State) -->
<div v-if="!course.chapters || course.chapters.length === 0" class="flex flex-col items-center justify-center py-12 text-slate-400 dark:text-slate-500 bg-white/50 dark:bg-slate-900/50 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="menu_book" size="40px" class="mb-2 opacity-50" />
<p class="text-sm font-medium">{{ $t('course.noContent') }}</p>
@ -185,11 +185,11 @@ const instructorData = computed(() => {
</div>
<!-- Right: Enrollment Card -->
<!-- ด้านขวา: การ์ดลงทะเบียน (Right: Enrollment Card) -->
<div class="lg:col-span-1">
<div class="sticky top-24">
<div class="bg-white dark:bg-slate-900 rounded-3xl shadow-2xl shadow-blue-500/10 dark:shadow-none p-8 border border-slate-100 dark:border-white/5 relative overflow-hidden group">
<!-- Decorative background glow -->
<!-- ลูกเล่นแสงพื้นหลังตกแต่ง (Decorative background glow) -->
<div class="absolute -top-12 -right-12 w-48 h-48 bg-blue-500/10 rounded-full blur-3xl group-hover:bg-blue-500/20 transition-colors"></div>
<div class="relative">

View file

@ -1,16 +1,16 @@
<script setup lang="ts">
/**
* @file AppHeader.vue
* @description The main header for the EduLearn application dashboard.
* @description แถบเมนูด้านบนหลัก (Header) สำหรับหน้าแดชบอร์ด (Dashboard) ของระบบ EduLearn
*/
const props = defineProps<{
/** Controls visibility of the sidebar toggle button */
/** ควบคุมการแสดงผลของปุ่มเปิด/ปิดแถบเมนูด้านข้าง (Sidebar) */
showSidebarToggle?: boolean;
}>();
const emit = defineEmits<{
/** Emitted when the hamburger menu is clicked */
/** ส่งสัญญาณ (Emit) เมื่อผู้ใช้คลิกที่ปุ่มแฮมเบอร์เกอร์เมนู */
toggleSidebar: [];
}>();
@ -30,7 +30,7 @@ const toggleTheme = () => {
<template>
<q-toolbar class="bg-white dark:!bg-[#020617] text-slate-900 dark:!text-white h-20 border-b border-slate-50 dark:border-slate-800/50 px-6">
<!-- Left: Hamburger Toggle -->
<!-- ด้านซ้าย: ปุ่มย่อขยายแถบเมนูด้านข้าง (Hamburger Toggle) -->
<q-btn
flat
round
@ -43,10 +43,10 @@ const toggleTheme = () => {
<q-space />
<!-- Right Section -->
<!-- ส่วนการตั้งค่าทางด้านขวา (Right Section) -->
<div class="flex items-center gap-2 sm:gap-4 md:gap-6 no-wrap">
<!-- Theme Toggle -->
<!-- ปุ่มสลับธีม (Theme Toggle) -->
<q-btn
flat
round
@ -60,7 +60,7 @@ const toggleTheme = () => {
<q-tooltip>{{ isDark ? 'โหมดกลางคืน' : 'โหมดกลางวัน' }}</q-tooltip>
</q-btn>
<!-- Language Switcher (Pill Style) -->
<!-- ตัวสลับภาษาแบบแคปซูล (Language Switcher) -->
<div
@click="toggleLanguage"
class="flex items-center bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800 rounded-xl p-0.5 sm:p-1 cursor-pointer hover:bg-slate-100 transition-all font-bold text-[11px] sm:text-[13px] select-none"
@ -70,7 +70,7 @@ const toggleTheme = () => {
<div :class="locale === 'en' ? 'bg-white dark:bg-slate-700 shadow-sm text-blue-600' : 'text-slate-400'" class="px-2 sm:px-3 py-1 rounded-lg transition-all">EN</div>
</div>
<!-- Divider -->
<!-- เส้นคั่น (Divider) -->
<div class="hidden sm:block w-[1px] h-8 bg-slate-100 dark:bg-slate-800"></div>
<!-- ส่วนข้อมูลผู้ใช้งาน (User Profile) -->
@ -102,12 +102,12 @@ const toggleTheme = () => {
</template>
<style scoped>
/* Ensure toolbar height is consistent */
/* บังคับให้ความสูงของ Header เท่ากันเสมอ (Ensure toolbar height is consistent) */
:deep(.q-toolbar) {
min-height: 80px;
}
/* Hide user name only on small mobile screens */
/* ซ่อนชื่อผู้ใช้ไว้เฉพาะบนหน้าจอมือถือขนาดเล็กเท่านั้น (Hide user name only on small mobile screens) */
@media (max-width: 600px) {
.user-info-text {
display: none !important;

View file

@ -51,7 +51,7 @@ const handleLogout = () => {
<template>
<div class="flex flex-col h-full bg-white dark:!bg-[#04091a] px-4 py-6 border-r border-slate-100 dark:border-slate-800">
<!-- Logo Section -->
<!-- โลโก้แบรนด์ (Logo Section) -->
<div class="flex items-center gap-3 px-2 mb-10 transition-transform active:scale-95 cursor-pointer" @click="navigateTo('/dashboard')">
<div class="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<q-icon name="school" color="white" size="24px" />
@ -59,7 +59,7 @@ const handleLogout = () => {
<span class="text-[22px] font-black tracking-tight text-slate-800 dark:text-white">EduLearn</span>
</div>
<!-- Main Navigation -->
<!-- การนำทางหลัก (Main Navigation) -->
<div class="space-y-1 mb-8">
<NuxtLink
v-for="item in menuItems"
@ -71,12 +71,12 @@ const handleLogout = () => {
<q-icon :name="item.icon" size="24px" class="transition-colors" />
<span class="font-bold text-[15px]">{{ item.label }}</span>
<!-- Active Indicator -->
<!-- ตัวบ่งชี้หน้าปัจจุบัน (Active Indicator) -->
<div v-if="route.path === item.to" class="absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-6 bg-blue-600 rounded-r-full shadow-[2px_0_8px_rgba(37,99,235,0.4)]"></div>
</NuxtLink>
</div>
<!-- Account Section -->
<!-- หมวดหมู่บัญชี (Account Section) -->
<div class="px-4 mb-4">
<span class="text-[12px] font-bold text-slate-400 uppercase tracking-widest">{{ $t('sidebar.accountGroup') }}</span>
</div>
@ -92,7 +92,7 @@ const handleLogout = () => {
<span class="font-bold text-[15px]">{{ item.label }}</span>
</NuxtLink>
<!-- Logout Button -->
<!-- ปุ่มออกจากระบบ (Logout Button) -->
<button
@click="handleLogout"
class="w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all text-red-500 hover:bg-red-50 dark:hover:bg-red-900/10 font-bold text-[15px] group"
@ -104,7 +104,7 @@ const handleLogout = () => {
<q-space />
<!-- Promo Card -->
<!-- การ์ดโปรโมชั่น (Promo Card) -->
<div class="mt-auto p-5 rounded-[2rem] bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800 relative overflow-hidden group">
<div class="relative z-10">
<h4 class="font-black text-slate-800 dark:text-white text-sm mb-1">{{ $t('sidebar.promoTitle') }}</h4>
@ -118,7 +118,7 @@ const handleLogout = () => {
</q-btn>
</div>
<!-- Subtle background decoration -->
<!-- การตกแต่งพื้นหลังแบบจางๆ (Subtle background decoration) -->
<div class="absolute -right-2 -bottom-2 w-16 h-16 bg-blue-500/5 rounded-full blur-xl"></div>
</div>
</div>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file LandingFooter.vue
* @description Footer component for the landing page - Adjusted to Image 2 (E-Learning Platform Branding)
* @description ส่วนท้ายของหน้าแรก (Footer component for the landing page)
*/
</script>
@ -9,7 +9,7 @@
<footer class="bg-white pt-16 pb-8 border-t border-slate-200">
<div class="container mx-auto px-6 md:px-12">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 mb-12 text-left">
<!-- Brand -->
<!-- โลโก้และชื่อแบรนด์ (Brand) -->
<div class="space-y-6">
<NuxtLink to="/" class="flex items-center gap-3 group">
<div class="bg-blue-600 text-white font-black rounded-full px-6 w-10 h-10 flex items-center justify-center group-hover:scale-110 transition-transform">
@ -29,7 +29,7 @@
</p>
</div>
<!-- Links -->
<!-- ลิงก์ต่างๆ (Links) -->
<div class="lg:pl-8">
<h4 class="font-bold text-slate-900 mb-6 text-base tracking-tight">คอร์สเรียน</h4>
<ul class="space-y-3 text-sm text-slate-500 flex flex-col gap-2">
@ -39,7 +39,7 @@
</ul>
</div>
<!-- Support -->
<!-- การสนับสนุนผู้ใช้ (Support) -->
<div>
<h4 class="font-bold text-slate-900 mb-6 text-base">ช่วยเหลือ</h4>
<ul class="space-y-3 text-sm text-slate-500 flex flex-col gap-2">
@ -50,11 +50,11 @@
</ul>
</div>
<!-- Contact (Bronco Hourse Data) -->
<!-- ข้อมูลการติดต่อ (Contact) -->
<div class="space-y-6">
<h4 class="font-bold text-slate-900 text-base">ติดต่อเรา</h4>
<div class="flex flex-col gap-5">
<!-- Location -->
<!-- สถานที่ตั้ง (Location) -->
<div class="flex flex-row items-start gap-4 flex-nowrap">
<q-icon name="o_location_on" size="20px" color="slate-800" />
<div class="flex flex-col gap-1 min-w-0">
@ -65,18 +65,18 @@
</div>
</div>
<!-- Phone -->
<!-- เบอร์โทรศัพท์ (Phone) -->
<div class="flex flex-row items-center gap-4 flex-nowrap">
<q-icon name="o_phone" size="18px" color="slate-800" />
<a href="tel:052-076-025" class="font-semibold text-slate-800 text-sm hover:text-blue-600 font-semibold text-sm transition-colors truncate">
<a href="tel:052-076-025" class="font-semibold text-slate-800 text-sm hover:text-blue-600 transition-colors truncate">
052-076-025
</a>
</div>
<!-- Email -->
<!-- อีเมล (Email) -->
<div class="flex flex-row items-center gap-4 flex-nowrap">
<q-icon name="o_email" size="18px" color="slate-800" />
<a href="mailto:info@chamomind.com" class="font-semibold text-slate-800 text-sm hover:text-blue-600 font-semibold text-sm transition-colors truncate">
<a href="mailto:info@chamomind.com" class="font-semibold text-slate-800 text-sm hover:text-blue-600 transition-colors truncate">
info@chamomind.com
</a>
</div>
@ -84,7 +84,7 @@
</div>
</div>
<!-- Bottom Bar (Centered Copyright) -->
<!-- แถบด้านล่างสำหรับสงวนลิขสิทธิ์ (Bottom Bar - Centered Copyright) -->
<div class="pt-8 border-t border-slate-200 text-center">
<p class="text-sm text-slate-400 font-medium tracking-wide">
Copyright © CHAMOMIND CO., LTD. 2023

View file

@ -1,23 +1,22 @@
<script setup lang="ts">
/**
* @file LandingHeader.vue
* @description The main header for the public landing pages.
* Features a transparent background that becomes solid/glass upon scrolling.
* Responsive: Collapses into a drawer on mobile (md breakpoint).
* @description คอมโพเนนต์แถบเมนูด้านบน (Header Navigation) สำหรับหน้า Landing Page และหน้าเปิดอื่นๆ
* รองรับการเปลี่ยนภาษา เปลี่ยนโหมดสว่าง/มืด และเข้าถึงเมนูผู้ใช้ (Profile/Logout)
*/
const text = ref('');
// Track scrolling state to adjust header styling
// ติดตามสถานะการเลื่อนเมาส์ (scroll) เพื่อปรับรูปแบบของ Header
const isScrolled = ref(false)
const { isAuthenticated } = useAuth()
// Mobile Drawer State
// สถานะเมนูสำหรับหน้าจอมือถือ (Mobile Drawer State)
// ตัวแปรควบคุมการเปิด/ปิดเมนูบนหน้าจอขนาดเล็ก (Mobile Drawer)
const mobileMenuOpen = ref(false)
const handleScroll = () => {
isScrolled.value = window.scrollY > 20
}
// Lock body scroll when mobile menu is open
// ปิดการเลื่อนหน้าเว็บ (Lock body scroll) เมื่อเปิดเมนูบนมือถือ
watch(mobileMenuOpen, (isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden'
@ -32,21 +31,21 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
document.body.style.overflow = '' // Cleanup
document.body.style.overflow = '' // คืนค่าเริ่มต้น (Cleanup)
})
</script>
<template>
<!--
Header Container
- Transitions between transparent and glass effect based on scroll.
คอนเทนเนอร์ของ Header (Header Container)
- เปลี่ยนจากสีใส (transparent) เป็นเอฟเฟกต์กระจก (glass effect) เมื่อเลื่อนเมาส์ลง
-->
<header
class="fixed top-0 left-0 right-0 z-[100] transition-all"
:class="[isScrolled ? 'h-20 glass-nav shadow-lg' : 'h-20 bg-transparent duration-300 border-b border-b-grey-7 ']"
>
<div class="container mx-auto px-6 md:px-12 h-full flex items-center justify-between">
<!-- Left Section: Logo -->
<!-- ด้านซ้าย: โลโก้แบรนด์ (Left Section: Logo) -->
<NuxtLink to="/" class="flex items-center gap-3 group">
<div class="bg-blue-600 text-white font-black rounded-full px-6 w-10 h-10 flex items-center justify-center group-hover:scale-110 transition-transform">
<q-icon name="o_school" size="24px" />
@ -67,7 +66,7 @@ onUnmounted(() => {
</div>
</NuxtLink>
<!-- Desktop Navigation (Visible by default, hidden on mobile via CSS 'desktop-nav') -->
<!-- การนำทางสำหรับเดสก์ท็อป (แสดงผลเป็นค่าเริ่มต้น, ซ่อนบนมือถือผ่าน CSS 'desktop-nav') -->
<!-- <nav class="flex desktop-nav items-center gap-8 text-[16px] font-medium">
<NuxtLink
to="/browse"
@ -89,10 +88,10 @@ onUnmounted(() => {
<!-- Desktop Action Buttons (Visible by default, hidden on mobile via CSS 'desktop-nav') -->
<!-- ปุ่มปฏิบัติการสำหรับเดสก์ท็อป (แสดงผลเป็นค่าเริ่มต้น, ซ่อนบนมือถือผ่าน CSS 'desktop-nav') -->
<div class="flex desktop-nav items-center gap-4">
<template v-if="!isAuthenticated">
<!-- Login Button -->
<!-- ปุ่มเข้าสู่ระบบ (Login Button) -->
<NuxtLink
to="/auth/login"
class="px-5 py-4 rounded-full text-slate-700 font-semibold text-sm transition-all hover:-translate-y-0.5"
@ -101,7 +100,7 @@ onUnmounted(() => {
{{ $t('auth.login') }}
</NuxtLink>
<!-- Register Button -->
<!-- ปุ่มสมัครสมาชิก (Register Button) -->
<NuxtLink
to="/auth/register"
class="px-5 py-4 rounded-full bg-blue-600 text-white font-semibold text-sm hover:shadow-blue-600/40 hover:-translate-y-0.5 transition-all"
@ -120,7 +119,7 @@ onUnmounted(() => {
</template>
</div>
<!-- Mobile Menu Button (Visible on Mobile) -->
<!-- ปุ่มเปิดเมนูบนมือถือ (แสดงผลเฉพาะบน Mobile) -->
<button
class="md:hidden mobile-toggle ml-auto relative z-[120] w-10 h-10 flex items-center justify-center rounded-full transition-colors"
:class="[isScrolled ? 'text-white hover:bg-white/10' : 'text-slate-900 hover:bg-slate-100', mobileMenuOpen ? 'text-slate-900 z-[120]' : '']"
@ -132,7 +131,7 @@ onUnmounted(() => {
</div>
</header>
<!-- Mobile Navigation Drawer (Teleported to body to avoid z-index/clipping issues with Header) -->
<!-- ลิ้นชักเมนูด้านข้างสำหรับมือถือ (Mobile Navigation Drawer - แยกส่วนไปยัง body เพื่อไม่ให้มีปัญหา z-index หรือถูกบัง) -->
<Teleport to="body">
<div v-if="mobileMenuOpen">
<div
@ -146,7 +145,7 @@ onUnmounted(() => {
:class="[mobileMenuOpen ? 'translate-x-0' : 'translate-x-full']"
>
<div class="p-6 pt-8 flex flex-col gap-6 h-full overflow-y-auto relative">
<!-- Close Button -->
<!-- ปุ่มปิดเมนู (Close Button) -->
<button
class="absolute top-6 right-6 w-8 h-8 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 transition-colors"
@click="mobileMenuOpen = false"
@ -154,7 +153,7 @@ onUnmounted(() => {
<q-icon name="close" size="20px" />
</button>
<!-- Mobile Links -->
<!-- ลิงก์สำหรับมือถือ (Mobile Links) -->
<nav class="flex flex-col gap-2 mt-8">
<NuxtLink
to="/"
@ -210,14 +209,14 @@ onUnmounted(() => {
</template>
<style scoped>
/* Glassmorphism Effect for Scrolled Header */
/* เอฟเฟกต์ Glassmorphism สำหรับ Header ตอนเลื่อนเมาส์ลง */
.glass-nav {
background: rgba(15, 23, 42, 0.95); /* Darker background for legibility */
background: rgba(15, 23, 42, 0.95); /* พื้นหลังเข้มขึ้นเพื่อให้อ่านตัวหนังสือชัดเจน */
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
/* Premium Primary Button Styling */
/* สไตล์ปุ่มหลัก (Primary Button) แบบพรีเมียม */
.btn-primary-premium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
@ -236,7 +235,7 @@ onUnmounted(() => {
box-shadow: 0 8px 20px -4px rgba(37, 99, 235, 0.5);
}
/* Secondary Premium Button Styling */
/* สไตล์ปุ่มดรอง (Secondary Button) แบบพรีเมียม */
.btn-secondary-premium {
padding: 0.75rem 1.5rem;
border-radius: 0.75rem;
@ -260,11 +259,11 @@ onUnmounted(() => {
}
/*
Force Visibility Logic to bypass potential Tailwind Build issues
Ensures Desktop and Mobile parts are strictly separated
โลจิกบังคับการแสดงผล เพื่อแก้ปัญหาการคอมไฟล์ของ Tailwind
ยืนยันว่าส่วน Desktop และ Mobile เลย์เอาต์แยกจากกันอย่างชัดเจน
*/
.desktop-nav {
display: flex; /* Default to visible */
display: flex; /* แสดงผลเป็นค่าเริ่มต้น */
}
@media (max-width: 767.98px) {

View file

@ -31,7 +31,7 @@ const handleNavigate = (path: string) => {
</template>
<style scoped>
/* Optional shadow for better separation */
/* เงาด้านบนแบบบางๆ เพื่อแบ่งส่วนล่างให้ชัดเจนขึ้น (Optional shadow for better separation) */
.shadow-up-1 {
box-shadow: 0 -1px 3px rgba(0,0,0,0.05);
}

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file PasswordChangeForm.vue
* @description From for changing user password
* @description ฟอร์มสำหรับเปลี่ยนรหัสผ่านของผู้ใช้ (From for changing user password)
*/
const props = defineProps<{
@ -130,7 +130,12 @@ const showConfirmPassword = ref(false);
<style scoped>
.card-premium {
@apply bg-white dark:bg-[#1e293b] border-slate-200 dark:border-white/5;
background-color: white;
border-color: #e2e8f0;
}
:global(.dark) .card-premium {
background-color: #1e293b;
border-color: rgba(255, 255, 255, 0.05);
border-radius: 1.5rem;
border-width: 1px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file ProfileEditForm.vue
* @description From for editing user personal information
* @description ฟอร์มสำหรับแก้ไขข้อมูลส่วนตัวของผู้ใช้ (Form for editing user personal information)
*/
const props = defineProps<{
@ -93,14 +93,14 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
<div class="absolute inset-0 bg-black/40 rounded-2xl flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<q-icon name="camera_alt" class="text-white text-xl" />
</div>
<!-- Hidden Input -->
<!-- ช่องเลือกไฟล์ที่ถูกซ่อนไว้ (Hidden Input) -->
<input ref="fileInput" type="file" class="hidden" accept="image/*" @change="handleFileUpload" >
</div>
<div class="flex flex-col gap-2">
<div class="font-bold text-slate-900 dark:text-white mb-1">{{ $t('profile.yourAvatar') }}</div>
<!-- Buttons Row -->
<!-- แถวปุ่มกด (Buttons Row) -->
<div class="flex items-center gap-3">
<template v-if="modelValue.photoURL">
<q-btn
@ -124,7 +124,7 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
</template>
</div>
<!-- Add Limit Text -->
<!-- ข้อความจำกัดขนาดไฟล์ (Add Limit Text) -->
<div class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ $t('profile.uploadLimit') }}
</div>
@ -248,7 +248,12 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
<style scoped>
.card-premium {
@apply bg-white dark:bg-[#1e293b] border-slate-200 dark:border-white/5;
background-color: white;
border-color: #e2e8f0;
}
:global(.dark) .card-premium {
background-color: #1e293b;
border-color: rgba(255, 255, 255, 0.05);
border-radius: 1.5rem;
border-width: 1px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);

View file

@ -1,4 +1,8 @@
<script setup lang="ts">
/**
* @file UserAvatar.vue
* @description คอมโพเนนต์แสดงรูปโปรไฟล์ผู้ใช้ หากไม่มีรูปจะแสดงตัวอักษรย่อของชื่อ
*/
const props = defineProps<{
size?: number | string
photoURL?: string
@ -19,7 +23,7 @@ const avatarSize = computed(() => {
const initials = computed(() => {
const getFirstChar = (name?: string) => {
if (!name) return ''
// For Thai names, if the first char is a leading vowel (เ แ โ ใ ไ), skip it to get the consonant
// สำหรับชื่อภาษาไทย ถ้าตัวอักษรแรกเป็นสระนำหน้า (เ แ โ ใ ไ) ให้ข้ามไปเอาพยัญชนะ
const leadingVowels = ['เ', 'แ', 'โ', 'ใ', 'ไ']
if (leadingVowels.includes(name.charAt(0)) && name.length > 1) {
return name.charAt(1)
@ -36,7 +40,7 @@ const handleImageError = () => {
imageError.value = true
}
// Watch for photoURL changes to reset error state
// รีเซ็ตสถานะข้อผิดพลาดเมื่อรูปโปรไฟล์เปลี่ยน (Watch for photoURL changes to reset error state)
watch(() => props.photoURL, () => {
imageError.value = false
})

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file UserMenu.vue
* @description User profile dropdown menu component using Quasar.
* @description คอมโพเนนต์เมนู Dropdown ของโปรไฟล์ผู้ใช้ ใช้ Quasar
*/
import { ref, computed, onMounted } from 'vue'
@ -12,7 +12,7 @@ const { currentUser, logout } = useAuth()
const { t } = useI18n()
const $q = useQuasar()
// Use centralized theme management
// จัดการการเปลี่ยนธีมแบบรวมศูนย์ (Use centralized theme management)
const { isDark, set } = useThemeMode()
const isHydrated = ref(false)
@ -21,7 +21,7 @@ onMounted(() => {
isHydrated.value = true
})
// User Initials
// ตัวอักษรย่อของผู้ใช้ (User Initials)
const userInitials = computed(() => {
if (!currentUser.value) return ''
const f = currentUser.value.firstName?.charAt(0).toUpperCase() || 'U'

View file

@ -1,18 +1,21 @@
// Interface สำหรับข้อมูลหมวดหมู่ (Category)
/**
* @interface Category
* @description โครงสร้างข้อมูลหมวดหมู่ (Category)
*/
export interface Category {
id: number
name: {
name: { // ชื่อหมวดหมู่รองรับ 2 ภาษา
th: string
en: string
[key: string]: string
}
slug: string
description: {
slug: string // Slug สำหรับใช้งานบน URL
description: { // รายละเอียดหมวดหมู่
th: string
en: string
[key: string]: string
}
icon: string
icon: string // ไอคอนของหมวดหมู่อ้างอิงจาก Material Icons
sort_order: number
is_active: boolean
created_at: string
@ -20,7 +23,7 @@ export interface Category {
}
export interface CategoryData {
total: number
total: number // จำนวนหมวดหมู่ทั้งหมด
categories: Category[]
}
@ -30,18 +33,24 @@ interface CategoryResponse {
data: CategoryData
}
// Composable สำหรับจัดการข้อมูลหมวดหมู่
/**
* @composable useCategory
* @description จัดการข้อมูลหมวดหมู่ (Categories) การดึงข้อมูล และการจัดการ Cache (เก็บข้อมูลจำลองชั่วคราว)
*/
export const useCategory = () => {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBase as string
const { token } = useAuth()
// ใช้ useState เพื่อเก็บ Cached Data ระดับ Global (แชร์กันทุก Component)
// เก็บ Cache การดึงข้อมูลหมวดหมู่ในระดับ Global (ใช้ข้าม Component ได้โดยไม่ต้องโหลดใหม่)
const categoriesState = useState<Category[]>('categories_cache', () => [])
const isLoaded = useState<boolean>('categories_loaded', () => false)
// ฟังก์ชันดึงข้อมูลหมวดหมู่ทั้งหมด
// Endpoint: GET /categories
/**
* @function fetchCategories
* @description ดึงรายการหมวดหมู่ทั้งหมดจาก API (GET /categories)
* หากมีแคชอยู่แล้วจะดึงจาก State ทันที แต่อาจบังคับให้โหลดใหม่ (forceRefresh) ได้
*/
const fetchCategories = async (forceRefresh = false) => {
// ถ้ามีข้อมูลอยู่แล้วและไม่สั่งบังคับโหลดใหม่ ให้ใช้ข้อมูลเดิมทันที
if (isLoaded.value && !forceRefresh && categoriesState.value.length > 0) {
@ -62,7 +71,7 @@ export const useCategory = () => {
const categories = response.data?.categories || []
// เก็บข้อมูลลง State
// บันทึกรายการหมวดหมู่ลง State Cache
categoriesState.value = categories
isLoaded.value = true
@ -74,9 +83,9 @@ export const useCategory = () => {
} catch (err: any) {
console.error('Fetch categories failed:', err)
// Retry logic for 429 Too Many Requests
// กรณีเกิด Error 429 ระบบจะทำการหน่วงเวลา (1.5 วิ) และลองโหลดข้อมูลใหม่อีก 1 ครั้ง (Retry)
if (err.statusCode === 429 || err.status === 429) {
await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5s
await new Promise(resolve => setTimeout(resolve, 1500)); // หน่วงเวลา 1.5 วินาที
try {
const retryRes = await $fetch<CategoryResponse>(`${API_BASE_URL}/categories`, {
method: 'GET',

View file

@ -1,3 +1,7 @@
/**
* @interface ValidationRule
* @description กฎการตรวจสอบความถูกต้องของฟิลด์ (เช่น บังคับกรอก, เช็คความยาว, ตรวจอีเมล)
*/
export interface ValidationRule {
required?: boolean
minLength?: number
@ -8,10 +12,18 @@ export interface ValidationRule {
custom?: (value: string) => string | null
}
/**
* @interface FieldErrors
* @description รูปแบบการเก็บข้อผิดพลาด (Key ของฟิลด์ คู่กับข้อความแจ้งเตือน)
*/
export interface FieldErrors {
[key: string]: string
}
/**
* @composable useFormValidation
* @description ใช้สำหรับตรวจสอบความถูกต้อง (Validate) ของข้อมูลในแบบฟอร์ม
*/
export function useFormValidation() {
const errors = ref<FieldErrors>({})
@ -52,6 +64,7 @@ export function useFormValidation() {
return null
}
// ฟังก์ชันหลักที่เอาแบบฟอร์ม (formData) มาตรวจกับเช็คลิสต์ทั้งหมด (validationRules)
const validate = (
formData: Record<string, string>,
validationRules: Record<string, { rules: ValidationRule; label: string; messages?: Record<string, string> }>
@ -67,14 +80,18 @@ export function useFormValidation() {
}
}
// บันทึกข้อผิดพลาดที่พบทั้งหมดลงใน State
errors.value = newErrors
// คืนค่าบอกว่า "ฟอร์มนี้ผ่านทั้งหมดไหม" (true คือผ่านหมด)
return isValid
}
// ฟังก์ชันลบข้อผิดพลาดทิ้งทั้งหมด (สำหรับตอนเริ่มกรอกใหม่)
const clearErrors = () => {
errors.value = {}
}
// ฟังก์ชันลบข้อผิดพลาดเฉพาะฟิลด์ที่กำหนด
const clearFieldError = (field: string) => {
if (errors.value[field]) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete

View file

@ -1,22 +1,27 @@
/**
* @composable useMediaPrefs
* @description จัดการตั้งค่าระดับเสียงและสถานะปิดเสียง (Mute) ของวิดีโอ/สื่อ
* โดยจะเก็บค่าแยกตามบัญชีผู้ใช้งาน และสั่งประยุกต์ใช้กับ <video> ให้อัตโนมัติ
*/
export const useMediaPrefs = () => {
// 1. Global State
// ใช้ useState เพื่อแชร์ค่าเดียวกันทั่วทั้ง App (เช่น เปลี่ยนหน้าแล้วเสียงยังเท่าเดิม)
// 1. สถานะส่วนกลาง (Global State)
// ใช้ useState เพื่อแชร์ค่าเดียวกันทั่วหน้าเว็บ (เช่น เปลี่ยนหน้าแล้วระดับเสียงยังคงที่)
const volume = useState<number>('media_prefs_volume', () => 1)
const muted = useState<boolean>('media_prefs_muted', () => false)
const { user } = useAuth()
// 2. Storage Key Helper (User Specific)
// 2. ฟังก์ชันช่วยสร้าง Key สำหรับ Storage (เก็บแยกตาม User)
const getStorageKey = () => {
const userId = user.value?.id || 'guest'
return `media:prefs:v1:${userId}`
}
// 3. Save Logic (Throttled)
// 3. ระบบบันทึกการตั้งค่าลงเบราว์เซอร์ (Throttled เพื่อไม่ให้บันทึกถี่เกินไป)
let saveTimeout: ReturnType<typeof setTimeout> | null = null
const save = () => {
if (import.meta.server) return
if (import.meta.server) return // เลี่ยงไม่ได้ต้องทำงานบนฝั่ง Client เท่านั้น
if (saveTimeout) clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
@ -29,12 +34,12 @@ export const useMediaPrefs = () => {
}
localStorage.setItem(key, JSON.stringify(data))
} catch (e) {
console.error('Failed to save media prefs', e)
console.error('ไม่สามารถบันทึกการตั้งค่าสื่อได้', e)
}
}, 500) // Throttle 500ms
}, 500) // หน่วงเวลา 500ms
}
// 4. Load Logic
// 4. ระบบโหลดการตั้งค่าเก่าขึ้นมา (Load Logic)
const load = () => {
if (import.meta.server) return
@ -51,20 +56,20 @@ export const useMediaPrefs = () => {
}
}
} catch (e) {
console.error('Failed to load media prefs', e)
console.error('ไม่สามารถโหลดการตั้งค่าสื่อได้', e)
}
}
// 5. Setters (With Logic)
// 5. ฟังก์ชันสำหรับอัปเดตและสั่งบันทึกการตั้งค่า (Setters)
const setVolume = (val: number) => {
const clamped = Math.max(0, Math.min(1, val))
volume.value = clamped
// Auto unmute if volume increased from 0
// ยกเลิกปิดเสียงอัตโนมัติ ถ้าระดับเสียงเพิ่มขึ้นจาก 0
if (clamped > 0 && muted.value) {
muted.value = false
}
// Auto mute if volume set to 0
// ปิดเสียงอัตโนมัติ ถ้าระดับเสียงกลายเป็น 0
if (clamped === 0 && !muted.value) {
muted.value = true
}
@ -75,7 +80,7 @@ export const useMediaPrefs = () => {
const setMuted = (val: boolean) => {
muted.value = val
// Logic: Unmuting should restore volume if it was 0
// หากผู้ใช้กดยกเลิกการปิดเสียงขณะที่ระดับเสียงเคยเป็น 0 ควรตั้งค่าเริ่มต้นให้เป็น 1
if (!val && volume.value === 0) {
volume.value = 1
}
@ -83,15 +88,15 @@ export const useMediaPrefs = () => {
save()
}
// 6. Apply & Bind to Element (The Magic)
// 6. ฟังก์ชันจับคู่ใช้กับการเล่นสื่อ (ตย. <video ref="videoEl"> -> applyTo(videoEl.value))
const applyTo = (el: HTMLMediaElement | null | undefined) => {
if (!el) return () => {}
// Initial Apply
// ใส่ค่าตั้งต้นให้กับออบเจ็กต์สื่อ
el.volume = volume.value
el.muted = muted.value
// A. Watch State -> Update Element
// A. สังเกตการเปลี่ยนแปลงจาก State -> เพื่อส่งไปอัปเดต Element สื่อ
const stopVolWatch = watch(volume, (v) => {
if (Math.abs(el.volume - v) > 0.01) el.volume = v
})
@ -99,9 +104,9 @@ export const useMediaPrefs = () => {
if (el.muted !== m) el.muted = m
})
// B. Listen Element -> Update State (e.g. Native Controls)
// B. สังเกตการเปลี่ยนแปลงจาก Element (เช่น ผู้ใช้กดปุ่มเร่งเสียงในวิดีโอตรงๆ) -> เพื่อเอาค่ามาอัปเดต State
const onVolumeChange = () => {
// Update state only if diff allows (prevent loop)
// อัปเดตเฉพาะเมื่อมีความแตกต่างเพื่อหลีกเลี่ยง Loop อนันต์
if (Math.abs(el.volume - volume.value) > 0.01) {
volume.value = el.volume
save()
@ -113,7 +118,7 @@ export const useMediaPrefs = () => {
}
el.addEventListener('volumechange', onVolumeChange)
// Cleanup function
// ฟังก์ชันล้างค่าเพื่อเลิกติดตาม (Cleanup แบบส่งกลับ (Return))
return () => {
stopVolWatch()
stopMutedWatch()
@ -121,11 +126,11 @@ export const useMediaPrefs = () => {
}
}
// 7. Lifecycle & Sync
// 7. จังหวะวงจรชีวิตตอนโหลดเสร็จและระบบ Sync
if (import.meta.client) {
onMounted(() => {
load()
// Cross-tab sync
// ระบบ Sync กับแท็บหรือหน้าต่างเดียวกันหากถูกเปิดไว้
window.addEventListener('storage', (e) => {
if (e.key === getStorageKey()) {
load()

View file

@ -1,17 +1,19 @@
/**
* @file useNavItems.ts
* @description Single Source of Truth for navigation items used across the app (Sidebar, Mobile Nav, User Menu).
* @description ศูนย์รวมจัดการเมนูนำทาง (Navigation Items) เพื่อให้ใช้ข้อมูลชุดเดียวกันทั้งเว็บ
* (เช่น เมนูด้านข้าง, เมนูบนมือถือ, และเมนูโปรไฟล์ผู้ใช้)
*/
export interface NavItem {
to: string
labelKey: string
icon: string
showOn: ('sidebar' | 'mobile' | 'userMenu')[]
roles?: string[]
to: string // ลิงก์ปลายทาง
labelKey: string // คีย์ภาษาสำหรับ i18n
icon: string // ไอคอนจาก Material Icons
showOn: ('sidebar' | 'mobile' | 'userMenu')[] // กำหนดให้โชว์ที่ส่วนไหนบ้าง
roles?: string[] // กำหนดสิทธิ์ผู้ใช้ที่จะเห็น (ถ้ามี)
}
export const useNavItems = () => {
// เมนูทั้งหมดในระบบ กำหนดไว้ที่เดียว
const allNavItems: NavItem[] = [
{
to: '/dashboard',
@ -63,6 +65,7 @@ export const useNavItems = () => {
}
]
// คัดกรองเมนูที่จะเอาไปแสดงแต่ละตำแหน่ง
const sidebarItems = computed(() => allNavItems.filter(item => item.showOn.includes('sidebar')))
const mobileItems = computed(() => allNavItems.filter(item => item.showOn.includes('mobile')))
const userMenuItems = computed(() => allNavItems.filter(item => item.showOn.includes('userMenu')))

View file

@ -19,95 +19,107 @@ export interface AnswerState {
/**
* @composable useQuizRunner
* @description Manages the state and logic for running a quiz activity.
* @description จัดการ State (สถานะ) และ Logic สำหรับการทำแบบทดสอบ (Quiz)
* ครอบคลุมการแสดงคำถาม, บันทึกคำตอบ, และตรวจสอบการข้ามคำถาม
*/
export const useQuizRunner = () => {
// State
const questions = useState<QuizQuestion[]>('quiz-questions', () => []);
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({}));
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0);
const loading = useState<boolean>('quiz-loading', () => false);
const lastError = useState<string | null>('quiz-error', () => null);
// ================= State (สถานะเก็บค่าต่างๆ ของข้อสอบ) =================
const questions = useState<QuizQuestion[]>('quiz-questions', () => []); // เก็บรายการคำถามทั้งหมด
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({})); // เก็บคำตอบที่ผู้ใช้ตอบ แยกตาม ID คำถาม
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0); // ลำดับคำถามที่กำลังทำอยู่ปัจจุบัน (เริ่มที่ 0)
const loading = useState<boolean>('quiz-loading', () => false); // สถานะตอนกำลังกดเซฟหรือโหลดข้อมูล
const lastError = useState<string | null>('quiz-error', () => null); // เก็บข้อความแจ้งเตือนข้อผิดพลาดล่าสุด
// Getters
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]);
// ================= Getters (ดึงค่าที่ถูกประมวลผลแล้ว) =================
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]); // ดึงคำถามข้อปัจจุบัน
const currentAnswer = computed(() => {
const currentAnswer = computed(() => { // ดึงคำตอบในข้อปัจจุบัน
if (!currentQuestion.value) return null;
return answers.value[currentQuestion.value.id];
});
const totalQuestions = computed(() => questions.value.length);
const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1);
const isFirstQuestion = computed(() => currentQuestionIndex.value === 0);
const totalQuestions = computed(() => questions.value.length); // จำนวนคำถามทั้งหมดในแบบทดสอบ
const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1); // เช็คว่าใช่คำถามข้อสุดท้ายหรือไม่
const isFirstQuestion = computed(() => currentQuestionIndex.value === 0); // เช็คว่าใช่คำถามข้อแรกหรือไม่
// Actions
// ================= Actions (ฟังก์ชันหลักสำหรับการทำงาน) =================
// ฟังก์ชันเริ่มต้นสร้าง/โหลดข้อสอบ (กำหนดโครงสร้างพื้นฐาน)
function initQuiz(quizData: any) {
if (!quizData || !quizData.questions) return;
questions.value = quizData.questions;
currentQuestionIndex.value = 0;
currentQuestionIndex.value = 0; // รีเซ็ตไปที่ข้อ 1 ใหม่
answers.value = {};
lastError.value = null;
// เตรียมโครงสร้างคำตอบรองรับทุกข้อ
questions.value.forEach(q => {
answers.value[q.id] = {
questionId: q.id,
value: null,
is_saved: false,
status: 'not_started',
touched: false,
is_saved: false, // บันทึกและส่ง API เรียบร้อยหรือยัง
status: 'not_started', // สถานะเริ่มต้นของคำถาม
touched: false, // ผู้ใช้เคยเปิดเข้ามาดูข้อนีัหรือยัง
};
});
// เริ่มต้นบันทึกเวลา/เข้าสู่ข้อที่ 1 ทันทีเมื่ออธิบายเสร็จ
if (questions.value.length > 0) {
enterQuestion(questions.value[0].id);
}
}
// ฟังก์ชันสลับสถานะเมื่อกดเข้ามาที่คำถามนั้นๆ
function enterQuestion(qId: number) {
const ans = answers.value[qId];
if (ans) {
ans.touched = true;
if (ans.status === 'not_started' || ans.status === 'skipped') {
ans.status = 'in_progress';
ans.status = 'in_progress'; // เปลี่ยนสถานะเป็น 'กำลังทำ'
}
}
}
// ตรวจสอบเงื่อนไขว่าผู้ใช้สามารถออกจากคำถามปัจจุบันไปยังข้ออื่นได้หรือไม่
function canLeaveCurrent(): { allowed: boolean; reason?: string } {
if (!currentQuestion.value) return { allowed: true };
const q = currentQuestion.value;
const a = answers.value[q.id];
// สามารถออกได้ถ้าคำถามทำถูกหรือโจทย์อนุญาตให้ข้ามได้
if (a.status === 'completed' || a.is_saved) return { allowed: true };
if (q.is_skippable) return { allowed: true };
// บังคับให้ตอบถ้าไม่ได้อนุญาตให้ข้าม และไม่ได้ตอบ
if (!a.is_saved && a.value === null) {
return { allowed: false, reason: 'This question is required.' };
return { allowed: false, reason: 'ต้องการคำตอบสำหรับข้อบังคับนี้' };
}
return { allowed: true };
}
// ฟังก์ชันอัปเดตค่าตัวเลือกที่ผู้ใช้กดเลือกในข้อปัจจุบัน
function updateAnswer(val: any) {
if (!currentQuestion.value) return;
const qId = currentQuestion.value.id;
answers.value[qId].value = val;
// หากมีแก้ไขคำตอบหลังจากกดเซฟไปแล้ว ให้เปลี่ยนสถานะให้ระบบรู้ว่าต้องเซฟใหม่
if (answers.value[qId].is_saved) {
answers.value[qId].is_saved = false;
answers.value[qId].status = 'in_progress';
}
}
// ล็อกและบันทึกข้อสอบเมื่อกดปุ่ม "ตกลง/ส่งคำตอบ" สำหรับข้อนั้นๆ
async function saveCurrentAnswer() {
if (!currentQuestion.value) return;
const qId = currentQuestion.value.id;
const ans = answers.value[qId];
if (ans.value === null) {
lastError.value = "Please provide an answer.";
lastError.value = "กรุณาเลือกคำตอบอย่างน้อย 1 ตัวเลือก";
return false;
}
@ -115,30 +127,34 @@ export const useQuizRunner = () => {
lastError.value = null;
try {
// หมายเหตุ: การเชื่อมต่อ API หลักต้องทำที่ไฟล์ component, ตัวนี้จัดการแค่เรื่อง State
ans.is_saved = true;
ans.status = 'completed';
ans.status = 'completed'; // มาร์คว่าเป็นข้อที่ทำเสร็จแล้ว
ans.last_saved_at = new Date().toISOString();
return true;
} catch (e) {
lastError.value = "Failed to save answer.";
lastError.value = "เกิดข้อผิดพลาดในการบันทึกคำตอบ";
return false;
} finally {
loading.value = false;
}
}
// วินิจฉัยก่อนสั่งผู้ใช้ย้ายไปยังคำถามอื่นหน้าอื่นตามดัชนีระบุ
function handleLeaveLogic(targetIndex: number) {
if (targetIndex === currentQuestionIndex.value) return;
// ตรวจสอบขั้นสุดท้าย ป้องกันคนคลิกแอบหนีข้อที่บังคับทำ
const check = canLeaveCurrent();
if (!check.allowed) {
lastError.value = check.reason || "Required question.";
lastError.value = check.reason || "จำเป็นต้องตอบข้อนี้ก่อนข้าม";
return false;
}
const currQ = currentQuestion.value;
if (currQ) {
const currAns = answers.value[currQ.id];
// หากผู้ใช้ทิ้งขว้างโดยที่ไม่บังคับ ให้ทิ้งสถานะเป็นข้าม ('skipped')
if (currAns.status !== 'completed' && !currAns.is_saved) {
currAns.status = 'skipped';
}
@ -147,6 +163,7 @@ export const useQuizRunner = () => {
currentQuestionIndex.value = targetIndex;
lastError.value = null;
// ติดตามสถานะ 'touched' ในข้อใหม่ที่เข้าไปล่าสุด
if (questions.value[targetIndex]) {
enterQuestion(questions.value[targetIndex].id);
}

View file

@ -1,26 +1,41 @@
import { useQuasar } from 'quasar'
/**
* @composable useThemeMode
* @description จัดการระบบโหมดสว่าง/มืด (Light/Dark Theme) ของแพลตฟอร์ม
* ครอบคลุมการสั่งบันทึกการตั้งค่า, เปลี่ยนคลาส Tailwind และ Sync ซิงก์กับ Quasar UI
*/
export const useThemeMode = () => {
const $q = useQuasar()
// deterministic on SSR: default = light
// สถานะเริ่มต้นของโหมดมืด (สำหรับการทำ SSR ถูกเซ็ตเป็น false (สว่าง) ไว้ก่อน)
const isDark = useState<boolean>('theme:isDark', () => false)
// ฟังก์ชันใช้คลาสกับ Tag <html> เพื่อให้ Tailwind หรือ CSS รันโหมดมืด
const applyTheme = (value: boolean) => {
if (!process.client) return
if (!process.client) return // หากทำงานฝั่งเซิร์ฟเวอร์ จะไม่สั่งให้รัน DOM
// สลับคลาส 'dark' หรือปิด
document.documentElement.classList.toggle('dark', value)
// สั่งให้ Quasar (UI Framework) ปรับโหมดสีให้ตรงกัน (มืด/สว่าง)
$q.dark.set(value)
// บันทึกการตั้งค่าลงเครื่องระยะยาวเบราว์เซอร์
localStorage.setItem('theme', value ? 'dark' : 'light')
}
// จับตาดูเมื่อตัวแปรเปลี่ยนค่าค่อยทำการเปลี่ยนโหมดบนหน้าจอ
watch(isDark, (v) => applyTheme(v))
// ฟังก์ชันสั่งสลับโหมด ไปมา (Toggle)
const toggle = () => {
isDark.value = !isDark.value
}
// ฟังก์ชันสำหรับกำหนดตั้งค่าโหมดแบบเจาะจง
const set = (v: boolean) => {
isDark.value = v
}

View file

@ -1,6 +1,6 @@
/**
* @file landing.ts
* @description Static data for the landing page.
* @description ข้อมูลคงที่ (Static data) แบบจำลองที่เอาไว้แสดงผลบนหน้า Landing page
*/
export const CATEGORY_CARDS = [

View file

@ -19,7 +19,7 @@ onMounted(() => {
</script>
<template>
<!-- Auth Shell: Wrapper for authentication pages (Login, Register, etc.) -->
<!-- Auth Shell: ครอบคลุมการแสดงผลหน้าสำหรับเข้าสู่ระบบหรือสมัครสมาชิก -->
<div class="auth-shell bg-white w-full min-h-screen">
<slot />
</div>

View file

@ -1,11 +1,11 @@
<script setup lang="ts">
/**
* @file dashboard-index.vue
* @description Layout for the Dashboard Index page, without the sidebar.
* Uses Quasar QLayout for responsive structure.
* @description เลย์เอาต์สำหรับหน้าแรกของ Dashboard (ไม่มีแผงเมนูด้านข้างเพื่อเน้นพื้นที่เนื้อหา)
* ใช้โครงสร้างจาก Quasar QLayout เพื่อให้รองรับ Responsive
*/
// Initialize global theme management
// เปิดใช้คอมโพสซิเบิลจัดการธีมแบบ Global
useThemeMode()
const { currentUser, logout } = useAuth()
@ -18,7 +18,7 @@ const toggleRightDrawer = () => {
<template>
<q-layout view="hHh lpR fFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- Header -->
<!-- แถบด้านบนของโครงสร้าง (Header) -->
<q-header
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white"
>
@ -29,7 +29,7 @@ const toggleRightDrawer = () => {
/>
</q-header>
<!-- Master Mobile Drawer (The Everything Hub) -->
<!-- แถบลิ้นชักมือถือหลัก (เมนูรวมทุกอย่างเมื่อปัดหน้าจอ/กดไอคอนบนสัดส่วนเล็ก) -->
<q-drawer
v-model="rightDrawerOpen"
side="right"
@ -39,7 +39,7 @@ const toggleRightDrawer = () => {
:width="300"
>
<div class="flex flex-col h-full bg-white dark:bg-[#0f172a]">
<!-- 1. Account Section (Premium Look) -->
<!-- 1. ส่วนบัญชีผู้ใช้ (Account Section) ดีไซน์พรีเมียม -->
<div class="p-6 bg-slate-50/50 dark:bg-slate-800/30 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-3">
@ -62,10 +62,10 @@ const toggleRightDrawer = () => {
</div>
</div>
<!-- 2. Integrated Content Hub -->
<!-- 2. ศูนย์รวมเมนูและเนื้อหา (Integrated Content Hub) -->
<div class="flex-grow overflow-y-auto pt-4">
<q-list padding class="text-slate-600 dark:text-slate-300">
<!-- Navigation -->
<!-- การนำทาง (Navigation) -->
<q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2">เมนูหลัก</q-item-label>
<q-item to="/dashboard" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 font-bold" @click="rightDrawerOpen = false">
@ -85,10 +85,10 @@ const toggleRightDrawer = () => {
<q-separator class="my-4 mx-6 opacity-50" />
<!-- Tools & Settings -->
<!-- เครื่องมือทั่วไปและการตั้งค่าระบบ -->
<q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2">เครื่องมือและการตั้งค่า</q-item-label>
<!-- Language Selection -->
<!-- ปุ่มสลับภาษา -->
<q-item class="px-6 py-2">
<q-item-section avatar><q-icon name="language" size="22px" /></q-item-section>
<q-item-section>
@ -99,7 +99,7 @@ const toggleRightDrawer = () => {
</q-item-section>
</q-item>
<!-- Dark Mode Toggle -->
<!-- เปิดปิดโหมดมืด (Dark Mode Toggle) -->
<q-item class="px-6 py-2">
<q-item-section avatar><q-icon :name="isDark ? 'dark_mode' : 'light_mode'" size="22px" /></q-item-section>
<q-item-section>
@ -121,7 +121,7 @@ const toggleRightDrawer = () => {
</q-list>
</div>
<!-- 3. Bottom Actions -->
<!-- 3. ส่วนล่างสุด เช่น ล็อกเอาต์ หรือระบุเวอร์ชันระบบ -->
<div class="p-6 mt-auto border-t border-slate-100 dark:border-slate-800">
<q-btn
unelevated
@ -138,18 +138,16 @@ const toggleRightDrawer = () => {
</div>
</q-drawer>
<!-- Sidebar Removed for this layout -->
<!-- หมายเหตุ: สำหรับหน้านี้ จะไม่ได้มีการโชว์ Sidebar ที่เป็นลิ้นชักซ้าย -->
<!-- Main Content -->
<!-- พื้นที่แสดงเนื้อหาหลัก -->
<q-page-container>
<q-page class="relative">
<slot />
</q-page>
</q-page-container>
<!-- Mobile Bottom Nav - Optional, keeping it consistent with default but maybe not needed if full width?
If we remove sidebar, we might still want mobile nav if it's main navigation.
Let's keep it for now as it doesn't hurt. -->
<!-- เมนูปุ่มกดล่างหน้าจอบนมือถือ (สำหรับช่วยให้เข้าถึงหน้าหลักได้ไวขึ้น) -->
<q-footer
v-if="$q.screen.lt.md"
class="!bg-white dark:!bg-[#1e293b] text-primary"

View file

@ -1,7 +1,8 @@
<script setup lang="ts">
/**
* @file default.vue
* @description Master layout for the EduLearn platform.
* @description เลย์เอาต์หลัก (Master Layout) สำหรับผู้ใช้งานที่เข้าสู่ระบบแล้ว
* ประกอบด้วยแถบเมนูด้านบน (Header), แถบเมนูด้านข้าง (Sidebar) และพื้นที่เนื้อหา
*/
useThemeMode()
@ -13,7 +14,7 @@ const toggleLeftDrawer = () => {
}
const route = useRoute()
// Show sidebar for these routes
// ระบุว่า path ไหนบ้างที่ให้แสดงแถบเมนูด้านข้าง (Sidebar)
const isDashboardRoute = computed(() => {
const routes = ['/dashboard', '/browse', '/classroom', '/course']
return routes.some(r => route.path.startsWith(r))
@ -23,14 +24,14 @@ const isDashboardRoute = computed(() => {
<template>
<q-layout view="lHh Lpr lFf" class="bg-[#F8FAFC] dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- Header -->
<!-- ส่วนหัว (Header) -->
<q-header
class="bg-transparent text-slate-900 dark:!text-white border-none shadow-none"
>
<AppHeader @toggleSidebar="toggleLeftDrawer" />
</q-header>
<!-- Navigation Sidebar -->
<!-- แถบเมนูด้านข้าง (Navigation Sidebar) -->
<q-drawer
v-model="leftDrawerOpen"
show-if-above
@ -42,7 +43,7 @@ const isDashboardRoute = computed(() => {
<AppSidebar />
</q-drawer>
<!-- Main Content Area -->
<!-- พื้นที่แสดงเนื้อหาหลัก (Main Content Area) -->
<q-page-container>
<q-page class="px-3 py-6 md:p-8">
<div class="max-w-[1600px] mx-auto">

View file

@ -23,20 +23,20 @@ onMounted(() => {
<q-layout view="lHh LpR lFf" class="bg-white text-slate-900">
<!-- Header (Transparent & Overlay) -->
<!-- ส่วนหัวของเพจ (แบบโปร่งใส และซ้อนทับให้โชว์เนื้อหาพื้นหลังได้) -->
<q-header class="bg-transparent" style="height: auto;">
<LandingHeader />
</q-header>
<!-- Main Content -->
<!-- padding-top: 0 forces content to go under the header (Hero effect) -->
<!-- ส่วนเนื้อหาหลัก -->
<!-- แทรก style padding-top: 0 บังคับให้เนื้อหาใต้ Header ชนชิดด้านบนสุด (ทำเป็น Hero section สวยๆ) -->
<q-page-container style="padding-top: 0 !important;">
<q-page>
<slot />
</q-page>
</q-page-container>
<!-- Footer -->
<!-- ส่วนท้ายของเพจ -->
<LandingFooter />

View file

@ -23,7 +23,7 @@ const isLoading = ref(false)
const rememberMe = ref(false)
const showPassword = ref(false)
// Form data model
// ข้อมูลฟอร์มสำหรับเก็บค่าอีเมลและรหัสผ่าน
const loginForm = reactive({
email: '',
password: ''
@ -31,7 +31,7 @@ const loginForm = reactive({
type LoginField = keyof typeof loginForm
// Validation rules definition
// การตั้งค่ากฎการตรวจสอบ (Validation Rules)
// กำหนดกฎการตรวจสอบข้อมูล (Validation Rules)
const loginRules = {
email: {
@ -108,12 +108,12 @@ const handleLogin = async () => {
}
// Show error on specific fields
// Show generic error for security (or specific if role mismatch)
// แสดงข้อผิดพลาดตามช่องหรือแสดงข้อผิดพลาดรวม (เพื่อความปลอดภัย)
// กรณีเข้าสู่ระบบไม่สำเร็จ
if (result.error === 'Email ไม่ถูกต้อง') {
errors.value.email = result.error // Role mismatch case
errors.value.email = result.error // กรณี Role ไม่ตรงกัน
} else {
// Generic login failure (401, 404, etc.)
// ข้อผิดพลาดแบบเหมาสำหรับปัญหาการเข้าสู่ระบบทั่วไป (เช่น รหัสผิด, ไม่พบผู้ใช้)
const msg = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'
errors.value.email = msg
errors.value.password = msg
@ -147,7 +147,7 @@ onMounted(() => {
========================================== -->
<div class="w-full max-w-[460px] relative z-10 slide-up">
<!-- Header / Logo -->
<!-- ส่วนหัวโปรไฟล์ / โลโก้ (Header / Logo) -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-tr from-blue-600 to-indigo-600 text-white shadow-lg shadow-blue-600/20 mb-6">
<span class="font-black text-2xl">E</span>
@ -158,10 +158,10 @@ onMounted(() => {
<div class="bg-white rounded-[2rem] p-8 md:p-10 shadow-xl shadow-slate-200/50 border border-slate-100 relative overflow-hidden">
<!-- Form -->
<!-- ฟอร์มเข้าสู่ระบบ (Login Form) -->
<form @submit.prevent="handleLogin" class="flex flex-col gap-5">
<!-- Email Input -->
<!-- ช่องกรอกอีเมล (Email Input) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">อีเมล</label>
<div class="relative group">
@ -180,7 +180,7 @@ onMounted(() => {
<span v-if="errors.email" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.email }}</span>
</div>
<!-- Password Input -->
<!-- ช่องกรอกรหัสผ่าน (Password Input) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหัสผ่าน</label>
<div class="relative group">
@ -206,7 +206,7 @@ onMounted(() => {
<span v-if="errors.password" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.password }}</span>
</div>
<!-- Options -->
<!-- ตัวเลือกเพิ่มเติม (จดจำฉัน, ลืมรหัสผ่าน) (Options) -->
<div class="flex items-center justify-between mt-1">
<label class="flex items-center gap-2.5 cursor-pointer group select-none">
<div class="relative flex items-center">
@ -227,7 +227,7 @@ onMounted(() => {
</NuxtLink>
</div>
<!-- Submit Button -->
<!-- ปุ่มยืนยันเข้าสู่ระบบ (Submit Button) -->
<button
type="submit"
:disabled="isLoading"
@ -237,7 +237,7 @@ onMounted(() => {
<div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
</button>
<!-- Test Credentials Box -->
<!-- กล่องแนะนำบัญชีสำหรับทดสอบ (Test Credentials Box) -->
<div class="mt-4 p-5 bg-blue-50/50 border border-blue-100 rounded-2xl flex flex-col items-center gap-2 animate-fade-in">
<div class="text-[11px] font-black uppercase tracking-[0.2em] text-blue-600 mb-1">บัญชีสำหรับทดสอบ (Test Account)</div>
<div class="flex flex-col items-center gap-1">
@ -253,7 +253,7 @@ onMounted(() => {
</form>
<!-- Register Link -->
<!-- ลิงก์สำหรับสมัครสมาชิกใหม่ (Register Link) -->
<div class="text-center mt-8">
<p class="text-slate-600 text-sm">
ยังไม่มีบัญชีสมาชิก?
@ -265,7 +265,7 @@ onMounted(() => {
</div>
<!-- Back Link -->
<!-- ลิงก์ย้อนกลับ (Back Link) -->
<div class="mt-8 text-center text-slate-500">
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm font-medium hover:text-slate-800 transition-colors group px-4 py-2 rounded-lg hover:bg-white/50">
<span class="group-hover:-translate-x-1 transition-transform">←</span> กลับไปหน้าแรก
@ -276,7 +276,7 @@ onMounted(() => {
</template>
<style scoped>
/* Animations */
/* เอฟเฟกต์การเคลื่อนไหว (Animations) */
@keyframes pulse-slow {
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.15); }

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
/**
* @file index.vue
* @description Page displaying all available courses in a public catalog format.
* Matches the requested modern layout.
* @description หน้าแสดงคอร์สเรียนทั้งหมดในรูปแบบแคตตาล็อกสาธารณะ
* ดีไซน์ปรับให้ทันสมัยเพื่อดึงดูดผู้ใช้งานใหม่
*/
definePageMeta({
@ -126,7 +126,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
<template>
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen pt-32 pb-20 px-4 md:px-8 transition-colors duration-300">
<div class="max-w-[1240px] mx-auto">
<!-- Catalog View -->
<!-- มุมมองแคตตาล็อกแสดงคอร์ส (Catalog View) -->
<div class="bg-white dark:bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:border-slate-800 min-h-[500px] mb-12">
<!-- ส่วนหัวและการค้นหา -->
@ -144,7 +144,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
</div>
</div>
<!-- Filters Category -->
<!-- ตัวกรองหมวดหมู่ (Filters Category) -->
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-10 w-full relative">
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<button
@ -165,16 +165,16 @@ const viewMode = ref<'grid' | 'list'>('grid')
</div>
</div>
<!-- Loader -->
<!-- ตัวแสดงการโหลด (Loader) -->
<div v-if="isLoading" class="flex justify-center py-24">
<q-spinner size="3rem" color="primary" />
</div>
<div v-else-if="filteredCourses.length > 0">
<!-- GRID VIEW -->
<!-- มุมมองแบบกริด (GRID VIEW) -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<NuxtLink v-for="course in filteredCourses" :key="course.id" :to="`/course/${course.id}`" class="flex flex-col rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer">
<!-- Thumbnail -->
<!-- รูปหน้าปก (Thumbnail) -->
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
@ -182,7 +182,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
</div>
</div>
<!-- Body -->
<!-- เนื้อหาคอร์ส (Body) -->
<div class="p-5 flex flex-col flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
@ -200,7 +200,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
<div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }}
</div>
<!-- Eye icon circle button -->
<!-- ปุ่มกดรูปตาเพื่อดูรายละเอียด (Eye icon circle button) -->
<button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 dark:text-slate-500 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-slate-700 border border-slate-100 dark:border-slate-700 transition-colors shadow-sm outline-none">
<q-icon name="visibility" size="18px" />
</button>
@ -209,7 +209,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
</NuxtLink>
</div>
<!-- LIST VIEW -->
<!-- มุมมองแบบรายการ (LIST VIEW) -->
<div v-else class="flex flex-col gap-5">
<NuxtLink v-for="course in filteredCourses" :key="course.id" :to="`/course/${course.id}`" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer">
<div class="relative w-full sm:w-[260px] aspect-[16/10] sm:aspect-auto sm:h-[160px] rounded-[1rem] bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0">
@ -243,7 +243,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
</div>
</div>
<!-- Empty State -->
<!-- กรณีไม่พบข้อมูลคอร์ส (Empty State) -->
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ searchQuery ? 'ไม่พบคอร์สที่คุณค้นหา' : 'ไม่มีคอร์สในหมวดหมู่นี้' }}</h3>
@ -258,7 +258,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
</template>
<style scoped>
/* Disable default scrollbar for filter container */
/* ปิดการแสดงแถบเลื่อนบนคอนเทนเนอร์ของตัวกรอง (Disable default scrollbar for filter container) */
.no-scrollbar::-webkit-scrollbar {
display: none;
}

View file

@ -22,7 +22,7 @@ const { user } = useAuth()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements, markLessonComplete, getLocalizedText } = useCourse()
const $q = useQuasar()
// State management
// การจัดการสถานะ (State management)
const sidebarOpen = ref(false)
const courseId = computed(() => Number(route.query.course_id))
@ -31,11 +31,11 @@ const courseId = computed(() => Number(route.query.course_id))
// ==========================================
// courseData: เก็บข้อมูลโครงสร้างคอร์ส (บทเรียนต่างๆ)
const courseData = ref<any>(null)
const announcements = ref<any[]>([]) // Announcements state
const showAnnouncementsModal = ref(false) // Modal state
const hasUnreadAnnouncements = ref(false) // Unread state tracking
const announcements = ref<any[]>([]) // สถานะของประกาศ (Announcements state)
const showAnnouncementsModal = ref(false) // สถานะของป๊อปอัป (Modal state)
const hasUnreadAnnouncements = ref(false) // ติดตามสถานะที่ยังไม่ได้อ่าน (Unread state tracking)
// Helper for persistent read status
// ฟังก์ชันช่วยเหลือสำหรับเก็บสถานะการอ่านถาวร (Helper for persistent read status)
const getAnnouncementStorageKey = () => {
if (!user.value?.id || !courseId.value) return ''
return `read_announcements:${user.value.id}:${courseId.value}`
@ -61,17 +61,17 @@ const checkUnreadAnnouncements = () => {
const lastReadDate = new Date(lastRead).getTime()
const hasNew = announcements.value.some(a => {
const annDate = new Date(a.created_at || Date.now()).getTime()
// Check if announcement is strictly newer than last read
// ตรวจสอบว่าประกาศใหม่กว่าที่อ่านครั้งล่าสุดหรือไม่ (Check if announcement is strictly newer than last read)
return annDate > lastReadDate
})
hasUnreadAnnouncements.value = hasNew
}
// Handler for opening announcements
// ฟังก์ชันจัดการเมื่อเปิดประกาศ (Handler for opening announcements)
const handleOpenAnnouncements = () => {
showAnnouncementsModal.value = true
hasUnreadAnnouncements.value = false // Clear unread badge on click
hasUnreadAnnouncements.value = false // ลบป้ายแจ้งเตือนเมื่อคลิก (Clear unread badge on click)
const key = getAnnouncementStorageKey()
if (key) {
@ -98,7 +98,7 @@ const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
// Logic Quiz Attempt Management
// การจัดการตรรกะจำนวนครั้งในการทำแบบทดสอบ (Logic Quiz Attempt Management)
const quizStatus = computed(() => {
if (!currentLesson.value || currentLesson.value.type !== 'QUIZ' || !currentLesson.value.quiz) return null
@ -106,7 +106,7 @@ const quizStatus = computed(() => {
const latestAttempt = quiz.latest_attempt
const allowMultiple = quiz.allow_multiple_attempts
// If never attempted
// หากยังไม่เคยทดสอบ (If never attempted)
if (!latestAttempt) {
return {
canStart: true,
@ -116,7 +116,7 @@ const quizStatus = computed(() => {
}
}
// If multiple attempts allowed
// หากอนุญาตให้ทดสอบได้หลายครั้ง (If multiple attempts allowed)
if (allowMultiple) {
return {
canStart: true,
@ -128,8 +128,8 @@ const quizStatus = computed(() => {
}
}
// allowMultiple is false (Single attempt only)
// Lock the quiz regardless of pass/fail once attempted
// ไม่อนุญาตให้สอบได้หลายครั้ง (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'),
@ -145,7 +145,7 @@ const handleStartQuiz = () => {
const quiz = currentLesson.value.quiz
// If multiple attempts are disabled and it's the first time
// หากทำได้ครั้งเดียวและนี่เป็นครั้งแรก (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>`,
@ -193,18 +193,18 @@ const resetAndNavigate = (path: string) => {
}
}
// 2. Clear all localStorage
// 2. ลบข้อมูลใน localStorage ทั้งหมด (Clear all localStorage)
localStorage.clear()
// 3. Restore ONLY whitelisted keys
// 3. คืนค่าเฉพาะคีย์ที่อนุญาต (Restore ONLY whitelisted keys)
Object.keys(whitelist).forEach(key => {
localStorage.setItem(key, whitelist[key])
})
// 4. Force hard reload to the new path
// 4. บังคับโหลดหน้าเว็บใหม่ไปยังเส้นทางใหม่ (Force hard reload to the new path)
window.location.href = path
} else {
// SSR Fallback
// การทำงานสำรองสำหรับ SSR (SSR Fallback)
router.push(path)
}
}
@ -213,13 +213,13 @@ const resetAndNavigate = (path: string) => {
const handleLessonSelect = (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return
// 1. Update URL query params
// 1. อัปเดตพารามิเตอร์ใน URL (Update URL query params)
router.push({ query: { ...route.query, lesson_id: lessonId.toString() } })
// 2. Load content without refresh
// 2. โหลดเนื้อหาโดยไม่ต้องรีเฟรชหน้าเว็บ (Load content without refresh)
loadLesson(lessonId)
// Close sidebar on mobile
// ปิดแถบด้านข้างบนมือถือ (Close sidebar on mobile)
if (sidebarOpen.value) {
sidebarOpen.value = false
}
@ -245,7 +245,7 @@ const loadCourseData = async () => {
if (res.success) {
courseData.value = res.data
// Auto-load logic: Check URL first, fallback to first available lesson
// ตรรกะการโหลดอัตโนมัติ: เช็ค 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) {
@ -258,7 +258,7 @@ const loadCourseData = async () => {
}
}
// Fetch Course Announcements
// ดึงข้อมูลประกาศของคอร์สเรียน (Fetch Course Announcements)
const annRes = await fetchCourseAnnouncements(courseId.value)
if (annRes.success) {
announcements.value = annRes.data || []
@ -275,7 +275,7 @@ const loadCourseData = async () => {
const loadLesson = async (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return
// Clear previous video state & unload component to force reset
// ล้างสถานะวิดีโอเดิมเพื่อบังคับตั้งค่าใหม่ (Clear previous video state & unload component to force reset)
isPlaying.value = false
videoProgress.value = 0
currentTime.value = 0
@ -285,16 +285,16 @@ const loadLesson = async (lessonId: number) => {
lastSavedTimestamp.value = 0
lastLocalSaveTimestamp.value = 0
currentDuration.value = 0
currentLesson.value = null // This will unmount VideoPlayer and hide content
currentLesson.value = null // ตัวนี้จะทำการซ่อนวิดีโอและซ่อนเนื้อหา (This will unmount VideoPlayer and hide content)
isLessonLoading.value = true
try {
// Optional: Check access first
// ป้องกันไว้ก่อน: ตรวจสอบสิทธิ์การเข้าถึง (Optional: Check access first)
const accessRes = await checkLessonAccess(courseId.value, lessonId)
if (accessRes.success && !accessRes.data.is_accessible) {
let msg = t('classroom.notAvailable')
// Handle specific lock reasons
// จัดการเหตุผลล็อกเฉพาะจุด (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) {
@ -314,32 +314,32 @@ const loadLesson = async (lessonId: number) => {
return
}
// 1. Fetch content
// 1. ดึงข้อมูลเนื้อหา (Fetch content)
const res = await fetchLessonContent(courseId.value, lessonId)
if (res.success) {
currentLesson.value = res.data
// Initialize progress object if missing (Critical for New Users)
// กำหนดค่าออบเจ็กต์ความคืบหน้าหากไม่มี (สำคัญสำหรับผู้ใช้ใหม่) (Initialize progress object if missing)
if (!currentLesson.value.progress) {
currentLesson.value.progress = {}
}
// Update Lesson Completion UI status safely
// อัปเดตสถานะสำเร็จของบทเรียนบน UI อย่างปลอดภัย (Update Lesson Completion UI status safely)
if (currentLesson.value?.progress?.is_completed && 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
lesson.is_completed = true // Standardize completion property
lesson.is_completed = true // ตั้งค่าสถานะสำเร็จให้เป็นมาตรฐาน (Standardize completion property)
break
}
}
}
// 2. Fetch Initial Progress (Resume Playback)
// 2. ดึงความคืบหน้าเริ่มต้นเพื่อเล่นต่อ (Fetch Initial Progress (Resume Playback))
if (currentLesson.value.type === 'VIDEO') {
// If already completed, clear local resume point to allow fresh re-watch
// หากเรียนจบแล้ว ให้ล้างจุดที่ดูล่าสุดเพื่อดูใหม่ได้ (If already completed, clear local resume point to allow fresh re-watch)
const isCompleted = currentLesson.value.progress?.is_completed || false
if (isCompleted) {
@ -351,7 +351,7 @@ const loadLesson = async (lessonId: number) => {
maxWatchedTime.value = 0
currentTime.value = 0
} else {
// Not completed? Resume from where we left off
// ถ้ายังเรียนไม่จบ? กลับไปเล่นจากจุดเดิม (Not completed? Resume from where we left off)
const progressRes = await fetchVideoProgress(lessonId)
let serverProgress = 0
if (progressRes.success && progressRes.data?.video_progress_seconds) {
@ -379,24 +379,24 @@ const loadLesson = async (lessonId: number) => {
}
}
// Video Player Ref (Component)
// ข้อมูลอ้างอิงวิดีโอ (Video Player Ref (Component))
const videoPlayerComp = ref(null)
// Video & Progress State
// สถานะความก้าวหน้าและวิดีโอ (Video & Progress State)
const initialSeekTime = ref(0)
const maxWatchedTime = ref(0) // Anti-rewind monotonic tracking
const maxWatchedTime = ref(0) // การติดตามแบบไม่ย้อนกลับ (Anti-rewind monotonic tracking)
const lastSavedTime = ref(-1)
const lastSavedTimestamp = ref(0) // Server throttle timestamp
const lastLocalSaveTimestamp = ref(0) // Local throttle timestamp
const currentDuration = ref(0) // Track duration for save logic
const lastSavedTimestamp = ref(0) // เวลาบันทึกในเซิร์ฟเวอร์ (Server throttle timestamp)
const lastLocalSaveTimestamp = ref(0) // เวลาบันทึกในเครื่อง (Local throttle timestamp)
const currentDuration = ref(0) // ติดตามเวลาของวิดีโอ (Track duration for save logic)
// Helper: Get Local Storage Key
// ฟังก์ชันช่วยเหลือ: ชื่อคีย์สำหรับ Local Storage (Helper: Get Local Storage Key)
const getLocalProgressKey = (lessonId: number) => {
if (!user.value?.id) return null
return `progress:${user.value.id}:${lessonId}`
}
// Helper: Get Local Progress
// ฟังก์ชันช่วยเหลือ: ดึงความคืบหน้าจากเครื่อง (Helper: Get Local Progress)
const getLocalProgress = (lessonId: number): number => {
try {
const key = getLocalProgressKey(lessonId)
@ -408,7 +408,7 @@ const getLocalProgress = (lessonId: number): number => {
}
}
// Helper: Save to Local Storage
// ฟังก์ชันช่วยเหลือ: บันทึกลงเครื่อง (Helper: Save to Local Storage)
const saveLocalProgress = (lessonId: number, time: number) => {
try {
const key = getLocalProgressKey(lessonId)
@ -416,31 +416,31 @@ const saveLocalProgress = (lessonId: number, time: number) => {
localStorage.setItem(key, time.toString())
}
} catch (e) {
// Ignore storage errors
// ข้ามกรณีเกิดข้อผิดพลาดในการบันทึก (Ignore storage errors)
}
}
// Handler: Video Time Update (from Component)
// ฟังก์ชันจัดการเวลาของวิดีโอ (จาก Component) (Handler: Video Time Update (from Component))
const handleVideoTimeUpdate = (cTime: number, dur: number) => {
currentDuration.value = dur || 0
// Update Monotonic Progress
// อัปเดตความคืบหน้าแบบทางเดียว (Update Monotonic Progress)
if (cTime > maxWatchedTime.value) {
maxWatchedTime.value = cTime
}
// Logic: Periodic Save
// ตรรกะ: บันทึกเป็นระยะ (Logic: Periodic Save)
if (currentLesson.value?.id) {
const now = Date.now()
// 1. Local Save Throttle (5 seconds)
// 1. การหน่วงเวลาบันทึกในเครื่อง (5 วินาที) (Local Save Throttle (5 seconds))
if (now - lastLocalSaveTimestamp.value > 5000) {
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
lastLocalSaveTimestamp.value = now
}
// 2. Server Save Throttle (handled inside performSaveProgress)
// Note: We don't check isPlaying here because if time is updating, it IS playing.
// 2. การหน่วงเวลาบันทึกบนเซิร์ฟเวอร์ (จัดการใน performSaveProgress)
// หมายเหตุ: เราไม่เช็ค isPlaying ตรงนี้เพราะถ้าเวลาเดินแปลว่าเล่นอยู่ (Note: We don't check isPlaying here because if time is updating, it IS playing.)
performSaveProgress(false, false)
}
}
@ -451,49 +451,49 @@ const onVideoMetadataLoaded = (duration: number) => {
}
}
const isCompleting = ref(false) // Flag to prevent race conditions during completion
const isCompleting = ref(false) // ตัวแปรกันการแย่งกันทำงานตอนเรียนจบ (Flag to prevent race conditions during completion)
// -----------------------------------------------------
// ROBUST PROGRESS SAVING SYSTEM (Hybrid: Local + Server)
// ระบบบันทึกความคืบหน้าที่แข็งแกร่ง (ไฮบริด: ในเครื่อง + เซิร์ฟเวอร์) (ROBUST PROGRESS SAVING SYSTEM (Hybrid: Local + Server))
// -----------------------------------------------------
// Main Server Save Function
// ฟังก์ชันหลักสำหรับบันทึกลงเซิร์ฟเวอร์ (Main Server Save Function)
const performSaveProgress = async (force: boolean = false, keepalive: boolean = false) => {
const lesson = currentLesson.value
if (!lesson || lesson.type !== 'VIDEO') return
// Ensure progress object exists
// ทำให้แน่ใจว่ามีออบเจ็กต์ความคืบหน้าอยู่ (Ensure progress object exists)
if (!lesson.progress) lesson.progress = {}
// 1. Completed Guard: Stop everything if already completed
// 1. ป้องกันเมื่อดูจบแล้ว: ไม่ต้องบันทึกอีกถ้าดูจบไปแล้ว (Completed Guard: Stop everything if already completed)
if (lesson.progress.is_completed) return
// 2. Race Condition Guard: Stop if currently completing
// 2. ป้องกันการเรียกซ้ำ: ไม่ทำงานถ้ากำลังบันทึกเวลาจบอยู่ (Race Condition Guard: Stop if currently completing)
if (isCompleting.value) return
const now = Date.now()
const maxSec = Math.floor(maxWatchedTime.value) // Use max watched time
const maxSec = Math.floor(maxWatchedTime.value) // ใช้เวลาที่ดูไปมากที่สุด (Use max watched time)
const durationSec = Math.floor(currentDuration.value || 0)
// 3. Monotonic Check: Allow saving 0 if it's the very first save (lastSavedTime is -1)
// 3. ตรวจสอบการเดินหน้า: ยอมให้บันทึก 0 ได้ถ้าเป็นการบันทึกครั้งแรก (lastSavedTime is -1) (Monotonic Check: Allow saving 0 if it's the very first save)
if (!force) {
if (lastSavedTime.value === -1) {
// First time save: allow 0 or more
// บันทึกครั้งแรก: ยอมรับ 0 หรือมากกว่า (First time save: allow 0 or more)
if (maxSec < 0) return
} else if (maxSec <= lastSavedTime.value) {
// Subsequent saves: must be greater than last saved
// การบันทึกครั้งถัดไป: ต้องมากกว่าครั้งล่าสุด (Subsequent saves: must be greater than last saved)
return
}
}
// 4. Throttle Check: Server Throttle (15 seconds)
// 4. ตรวจสอบการหน่วงเวลา: เซิร์ฟเวอร์ (15 วินาที) (Throttle Check: Server Throttle (15 seconds))
if (!force && (now - lastSavedTimestamp.value < 15000)) return
// Prepare for Save
// เตรียมบันทึก (Prepare for Save)
lastSavedTime.value = maxSec
lastSavedTimestamp.value = now
// Check if this save might complete the lesson (e.g. 100% or forced end)
// เช็คว่าการบันทึกนี้จะทำให้จบบทเรียนหรือไม่ (เช่น 100% หรือบังคับจบ) (Check if this save might complete the lesson)
const isFinishing = force || (durationSec > 0 && maxSec >= durationSec)
if (isFinishing) {
@ -503,8 +503,8 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
try {
const res = await saveVideoProgress(lesson.id, maxSec, durationSec, keepalive)
// Handle Completion (Frontend-only strategy: 95% threshold)
// This ensures the checkmark appears at 95% to match backend.
// จัดการเมื่อดูจบ (กลยุทธ์เฉพาะฝั่งหน้าเว็บ: ให้ผ่านที่ 95%) (Handle Completion (Frontend-only strategy: 95% threshold))
// เพื่อให้เครื่องหมายถูกขึ้นที่ 95% ตรงกับหลังบ้าน (This ensures the checkmark appears at 95% to match backend.)
const progressPercentage = durationSec > 0 ? (maxSec / durationSec) : 0
const isCompletedNow = res.success && (res.data?.is_completed || progressPercentage >= 0.95)
@ -513,7 +513,7 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
markLessonAsCompletedLocally(lesson.id)
if (lesson.progress) lesson.progress.is_completed = true
// If newly completed, reload course data to unlock next lesson in sidebar
// หากเพิ่งเรียนจบใหม่ ให้โหลดเนื้อหาใหม่เพื่อปลดล็อกบทถัดไป (If newly completed, reload course data to unlock next lesson in sidebar)
if (!wasAlreadyCompleted) {
await loadCourseData()
}
@ -527,13 +527,13 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
}
}
// Helper to update Sidebar UI
// ฟังก์ชันช่วยเหลือสำหรับอัปเดตแถบด้านข้าง (Helper to update Sidebar UI)
const markLessonAsCompletedLocally = (lessonId: number) => {
if (courseData.value) {
for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
if (lesson) {
// Compatible with API structure
// สอดคล้องกับโครงสร้าง API (Compatible with API structure)
lesson.is_completed = true
if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true
@ -547,11 +547,11 @@ const videoSrc = computed(() => {
if (!currentLesson.value) return ''
let url = ''
// Use explicit video_url from API first
// ใช่ video_url โดยตรงจาก API ก่อน (Use explicit video_url from API first)
if (currentLesson.value.video_url) {
url = currentLesson.value.video_url
} else {
// Fallback (deprecated logic)
// สำรอง (ตรรกะแบบเก่า)
const content = getLocalizedText(currentLesson.value.content)
if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) {
url = content
@ -560,7 +560,7 @@ const videoSrc = computed(() => {
if (!url) return ''
// Support Resume for YouTube
// รองรับการเล่นต่อสำหรับ YouTube (Support Resume for YouTube)
const isYoutube = url.toLowerCase().includes('youtube.com') || url.toLowerCase().includes('youtu.be')
if (isYoutube && initialSeekTime.value > 0) {
const separator = url.includes('?') ? '&' : '?'
@ -575,7 +575,7 @@ const onVideoEnded = async () => {
const lesson = currentLesson.value
if (!lesson) return
// Clear local storage on end since it's completed
// ล้าง localStorage เนื่องจากดจบแล้ว (Clear local storage on end since it's completed)
const key = getLocalProgressKey(lesson.id)
if (key && typeof window !== 'undefined') {
localStorage.removeItem(key)
@ -598,7 +598,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
// Clear state when leaving the page to ensure fresh start on return
// ล้างสถานะเมื่อออกจากหน้าเพื่อให้หน้าเหมือนใหม่ตอนกลับมา (Clear state when leaving the page to ensure fresh start on return)
courseData.value = null
currentLesson.value = null
})
@ -665,7 +665,7 @@ onBeforeUnmount(() => {
</q-toolbar>
</q-header>
<!-- Sidebar (Curriculum) - Positioned Right via component prop -->
<!-- แถบด้านข้าง (บทเรียน) - วางชิดขวาผ่านพร็อพพ์ -->
<CurriculumSidebar
v-model="sidebarOpen"
:courseData="courseData"
@ -676,14 +676,14 @@ onBeforeUnmount(() => {
@open-announcements="handleOpenAnnouncements"
/>
<!-- Main Content -->
<!-- พื้นที่เนื้อหาหลัก (Main Content) -->
<q-page-container class="bg-white dark:bg-slate-900">
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]">
<!-- Video Player & Content Area -->
<!-- กรอบวิดีโอและพื้นที่เนื้อหา (Video Player & Content Area) -->
<div class="w-full h-full p-4 md:p-6 flex-grow overflow-y-auto">
<!-- 1. LOADING STATE (Comprehensive Skeleton) -->
<!-- 1. สถานะกำลังโหลด (โครงสร้างเสมือน (Skeleton) สมบูรณ์แบบ) (LOADING STATE (Comprehensive Skeleton)) -->
<div v-if="isLessonLoading" class="animate-fade-in">
<!-- Video Skeleton -->
<!-- โครงภาพวิดีโอ (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"
@ -697,7 +697,7 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Info Skeleton -->
<!-- โครงข้อมูล (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>
@ -705,9 +705,9 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- 2. READY STATE (Real Lesson Content) -->
<!-- 2. สถานะพร้อมใช้งาน (ข้อมูลบทเรียนจริง) (READY STATE (Real Lesson Content)) -->
<div v-else-if="currentLesson" class="animate-fade-in">
<!-- Video Player -->
<!-- ส่วนการเล่นวิดีโอ (Video Player) -->
<VideoPlayer
v-if="videoSrc"
ref="videoPlayerComp"
@ -719,7 +719,7 @@ onBeforeUnmount(() => {
@loadedmetadata="(d: number) => onVideoMetadataLoaded(d)"
/>
<!-- Lesson Info -->
<!-- ข้อมูลบทเรียน (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">
@ -728,7 +728,7 @@ onBeforeUnmount(() => {
<p class="text-slate-600 dark:text-slate-400 text-base md:text-lg leading-relaxed mb-6" v-if="currentLesson.description">{{ getLocalizedText(currentLesson.description) }}</p>
<!-- Lesson Content Area (Text/HTML) -->
<!-- ช่องบทเรียน (Text/HTML) (Lesson Content Area) -->
<div v-if="currentLesson.type === 'QUIZ'" class="p-8 bg-gradient-to-br from-blue-50/50 to-indigo-50/50 dark:from-slate-800/50 dark:to-slate-900/50 rounded-2xl border border-blue-100 dark:border-white/5 text-center">
<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" />
@ -783,7 +783,7 @@ onBeforeUnmount(() => {
<div v-html="getLocalizedText(currentLesson.content)" class="leading-relaxed text-slate-800 dark:text-slate-200"></div>
</div>
<!-- Attachments Section -->
<!-- ส่วนเอกสารแนบ (Attachments Section) -->
<div v-if="currentLesson.attachments && currentLesson.attachments.length > 0" class="mt-8 pt-6 border-t border-gray-100 dark:border-white/5">
<h3 class="text-lg font-bold mb-4 text-slate-900 dark:text-white flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-orange-100 dark:bg-orange-900/30 text-orange-600 flex items-center justify-center">

View file

@ -1,9 +1,9 @@
<script setup lang="ts">
/**
* @file quiz.vue
* @description Quiz Interface.
* Manages the entire quiz lifecycle: Start -> Taking -> Results -> Review.
* Features a timer, question navigation, and detailed result analysis.
* @description หน้าสำหรับทำแบบทดสอบ (Quiz Interface)
* จัดการวงจรชีวิตของการทำแบบทดสอบทั้งหมด: เริ่มต้น -> ทำข้อสอบ -> ผลลัพธ์ -> ทบทวน
* มีฟีเจอร์จับเวลา การนำทางระหว่างคำถาม และการวิเคราะห์ผลลัพธ์อย่างละเอียด
*/
definePageMeta({
@ -17,7 +17,7 @@ const router = useRouter()
const $q = useQuasar()
const { fetchCourseLearningInfo, fetchLessonContent, submitQuiz: apiSubmitQuiz, markLessonComplete } = useCourse()
// State Management
// การจัดการสถานะ (State Management)
const currentScreen = ref<'start' | 'taking' | 'result' | 'review'>('start')
const timeLeft = ref(0)
let timerInterval: ReturnType<typeof setInterval> | null = null
@ -30,20 +30,20 @@ const quizData = ref<any>(null)
const isLoading = ref(true)
const isSubmitting = ref(false)
// Quiz Taking State
// สถานะขณะทำแบบทดสอบ (Quiz Taking State)
const currentQuestionIndex = ref(0)
const userAnswers = ref<Record<number, number>>({}) // questionId -> choiceId
const visitedQuestions = ref<Set<number>>(new Set()) // Track visited indices
const userAnswers = ref<Record<number, number>>({}) // ID คำถาม -> ID คำตอบ (questionId -> choiceId)
const visitedQuestions = ref<Set<number>>(new Set()) // ติดตามข้อที่เคยเปิดดูแล้ว (Track visited indices)
const quizResult = ref<any>(null)
// Tracking visited questions
// ติดตามคำถามที่เปิดดูแล้ว (Tracking visited questions)
watch(currentQuestionIndex, (newVal) => {
visitedQuestions.value.add(newVal)
}, { immediate: true })
// Helper: Get Status Color Class
// ฟังก์ชันช่วยเหลือ: ดึงคลาสสีสำหรับสถานะคำถาม (Helper: Get Status Color Class)
const getQuestionStatusClass = (index: number, questionId: number) => {
// 1. Current = Blue
// 1. ข้อปัจจุบัน = สีฟ้า (Current = Blue)
if (index === currentQuestionIndex.value) {
return 'bg-blue-500 text-white border-blue-600 ring-2 ring-blue-200 dark:ring-blue-900'
}
@ -51,32 +51,29 @@ const getQuestionStatusClass = (index: number, questionId: number) => {
const hasAnswer = userAnswers.value[questionId] !== undefined
const isVisited = visitedQuestions.value.has(index)
// 2. Completed = Green
// 2. ตอบแล้ว = สีเขียว (Completed = Green)
if (hasAnswer) {
return 'bg-emerald-500 text-white border-emerald-600'
}
// 3. Skipped = Orange (Visited but no answer)
// Note: If we are strictly following "Skipped" definition:
// "user pressed Skip or moved forward on a skippable question without saving an answer"
// In this linear flow, merely visiting and leaving empty counts as skipped.
// 3. ข้าม = สีส้ม (เปิดดูแล้วแต่ยังไม่ตอบ) (Skipped = Orange (Visited but no answer))
// หมายเหตุ: ตามคำนิยามของการข้าม หากคลิกแล้วหน้าเว็บไม่บันทึกข้อมูลจะถือว่าเป็นการข้าม
// ในขั้นตอนนี้การดูเฉยๆ แล้วผ่านถือเป็นการข้ามเช่นกัน
if (isVisited && !hasAnswer) {
return 'bg-orange-500 text-white border-orange-600'
}
// 4. Not Started = Grey
// 4. ยังไม่เริ่ม = สีเทา (Not Started = Grey)
return 'bg-slate-200 text-slate-400 border-slate-300 dark:bg-white/5 dark:border-white/5 dark:text-slate-600 hover:bg-slate-300 dark:hover:bg-white/10'
}
const jumpToQuestion = (targetIndex: number) => {
if (targetIndex === currentQuestionIndex.value) return
// Validation before leaving current (same logic as Next)
// ตรวจสอบก่อนออกจากข้อปัจจุบัน (Validation before leaving current)
if (targetIndex > currentQuestionIndex.value) {
// If jumping forward, we must validate the CURRENT question requirements
// unless we treat grid jumps as free navigation?
// Req: "user cannot go Next until the question is answered and saved" (if not skippable).
// So we must check restriction on the current spot before leaving.
// หากข้ามไปข้างหน้า จะต้องตรวจสอบว่าข้อปัจจุบันทำไปถึงไหนแล้ว
// ควรบังคับให้คลิกคำตอบก่อนจึงข้ามได้ (เงื่อนไขสำหรับคำถามที่ไม่ให้ข้าม)
const isAnswered = userAnswers.value[currentQuestion.value.id] !== undefined
const isSkippable = quizData.value?.is_skippable
@ -91,12 +88,12 @@ const jumpToQuestion = (targetIndex: number) => {
}
}
// If jumping backward? Usually allowed freely.
// อนุญาตให้ย้อนกลับได้อย่างอิสระ (If jumping backward? Usually allowed freely.)
currentQuestionIndex.value = targetIndex
}
// Computed
// ตัวแปรแบบ Computed (Computed Properties)
const currentQuestion = computed(() => {
if (!quizData.value || !quizData.value.questions) return null
return quizData.value.questions[currentQuestionIndex.value]
@ -116,7 +113,7 @@ const timerDisplay = computed(() => {
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
})
// Helper for localization
// ฟังก์ชันช่วยเหลือสำหรับการแปลภาษา (Helper for localization)
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
@ -126,7 +123,7 @@ const getLocalizedText = (text: any) => {
const lessonProgress = ref<any>(null)
// Data Fetching
// โหลดข้อมูล (Data Fetching)
const loadData = async () => {
isLoading.value = true
try {
@ -138,9 +135,9 @@ const loadData = async () => {
if (courseId && lessonId) {
const lessonRes = await fetchLessonContent(courseId, lessonId)
if (lessonRes.success) {
// Determine if data is directly the quiz or nested
// กำหนดข้อมูลแบบทดสอบ (Determine if data is directly the quiz or nested)
quizData.value = lessonRes.data.quiz || lessonRes.data
lessonProgress.value = lessonRes.progress // Capture progress
lessonProgress.value = lessonRes.progress // บันทึกความคืบหน้า (Capture progress)
if (quizData.value?.time_limit) {
timeLeft.value = quizData.value.time_limit * 60
}
@ -153,7 +150,7 @@ const loadData = async () => {
}
}
// Helper for shuffling
// ฟังก์ชันช่วยสุ่มเรียงลำดับอาร์เรย์ (Helper for shuffling)
const shuffleArray = <T>(array: T[]): T[] => {
return array
.map(value => ({ value, sort: Math.random() }))
@ -161,18 +158,18 @@ const shuffleArray = <T>(array: T[]): T[] => {
.map(({ value }) => value)
}
// Quiz Actions
// การกระทำในแบบทดสอบ (Quiz Actions)
const startQuiz = () => {
// Deep copy to reset and apply shuffle
// รีเซ็ตข้อมูลโดยคัดลอกใหม่เพื่อนำมาสุ่ม (Deep copy to reset and apply shuffle)
const rawQuiz = JSON.parse(JSON.stringify(quizData.value))
if (rawQuiz) {
// Shuffle Questions
// สุ่มคำถาม (Shuffle Questions)
if (rawQuiz.shuffle_questions && rawQuiz.questions) {
rawQuiz.questions = shuffleArray(rawQuiz.questions)
}
// Shuffle Choices
// สุ่มตัวเลือกคำตอบ (Shuffle Choices)
if (rawQuiz.shuffle_choices && rawQuiz.questions) {
rawQuiz.questions.forEach((q: any) => {
if (q.choices) {
@ -180,7 +177,7 @@ const startQuiz = () => {
}
})
}
// Update state with shuffled data
// อัปเดตข้อมูลให้เป็นแบบสุ่ม (Update state with shuffled data)
quizData.value = rawQuiz
}
@ -196,7 +193,7 @@ const startQuiz = () => {
}, 1000)
}
// Mark first as visited
// ทำเครื่องหมายข้อแรกว่าเปิดดูแล้ว (Mark first as visited)
visitedQuestions.value = new Set([0])
}
@ -209,12 +206,12 @@ const selectAnswer = (choiceId: number) => {
const nextQuestion = () => {
if (!currentQuestion.value) return
// Allow skip if quiz is skippable or question is answered
// อนุญาตให้ข้ามได้หากโหมดข้ามเปิดอยู่ หรือ ตอบแล้ว (Allow skip if quiz is skippable or question is answered)
const isAnswered = userAnswers.value[currentQuestion.value.id] !== undefined
const isSkippable = quizData.value?.is_skippable
if (!isAnswered && !isSkippable) {
// Show warning
// แสดงข้อความเตือน (Show warning)
$q.notify({
type: 'warning',
message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'),
@ -241,7 +238,7 @@ const retryQuiz = () => {
}
const submitQuiz = async (auto = false) => {
// 1. Manual Validation: Check if all questions are answered
// 1. ตรวจสอบก่อนว่าทำครบทุกข้อหรือยัง (Manual Validation: Check if all questions are answered)
if (!auto) {
const answeredCount = Object.keys(userAnswers.value).length
if (answeredCount < totalQuestions.value) {
@ -254,7 +251,7 @@ const submitQuiz = async (auto = false) => {
return
}
// Premium Confirmation before submission
// หน้าต่างยืนยันส่งข้อสอบแบบสวยงาม (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>`,
@ -285,33 +282,33 @@ const submitQuiz = async (auto = false) => {
}
const processSubmitQuiz = async (auto = false) => {
// 2. Start Submission Process
// 2. เริ่มขั้นตอนการส่งข้อสอบ (Start Submission Process)
if (timerInterval) clearInterval(timerInterval)
isSubmitting.value = true
currentScreen.value = 'result' // Switch to result screen to show progress
currentScreen.value = 'result' // เปลี่ยนหน้าไปที่หน้าสรุปผล (Switch to result screen to show progress)
try {
// Prepare Payload
// เตรียมข้อมูลยิง API (Prepare Payload)
const answersPayload = Object.entries(userAnswers.value).map(([qId, cId]) => ({
question_id: Number(qId),
choice_id: cId
}))
// Check if already passed
// ตรวจสอบว่าเคยผ่านแล้วหรือไม่ (Check if already passed)
const alreadyPassed = lessonProgress.value?.is_passed || lessonProgress.value?.is_completed || false
// Call API
// เรียก API (Call API)
const res = await apiSubmitQuiz(courseId, lessonId, answersPayload, alreadyPassed)
if (res.success && res.data) {
quizResult.value = res.data
// Update local progress if passed and not previously passed
// อัปเดตข้อมูลบนเว็บหากผ่านแบบทดสอบ (Update local progress if passed and not previously passed)
if (res.data.is_passed && !alreadyPassed) {
if (lessonProgress.value) lessonProgress.value.is_passed = true
}
} else {
// Fallback error handling
// การจัดการเมื่อเกิดข้อผิดพลาดฉุกเฉิน (Fallback error handling)
$q.notify({
type: 'negative',
message: res.error || 'Failed to submit quiz'
@ -364,14 +361,14 @@ const reviewQuiz = () => {
currentScreen.value = 'review'
}
// Helper to get choice label (A, B, C...)
// เปลี่ยน ID เป็นตัวอักษรเพื่อความสวยงาม (A, B, C...) (Helper to get choice label (A, B, C...))
const getChoiceLabel = (index: number) => {
return String.fromCharCode(65 + index) // 65 is 'A'
}
const getCorrectChoiceId = (questionId: number) => {
if (!quizResult.value?.answers_review) return null
// Type checking for safety
// เช็คข้อมูลก่อนพัง (Type checking for safety)
const review = Array.isArray(quizResult.value.answers_review)
? quizResult.value.answers_review.find((r: any) => r.question_id === questionId)
: null
@ -384,7 +381,7 @@ const getCorrectChoiceId = (questionId: number) => {
<q-page-container>
<q-page>
<div class="quiz-shell min-h-screen bg-slate-50 dark:bg-[#0b0f1a] text-slate-900 dark:text-slate-200 antialiased selection:bg-blue-500/20 transition-colors">
<!-- Header -->
<!-- ส่วนหัว (Header) -->
<header class="h-14 bg-white dark:!bg-[var(--bg-surface)] fixed top-0 inset-x-0 z-[100] flex items-center px-6 border-b border-slate-200 dark:border-white/5 transition-colors">
<div class="flex items-center w-full justify-between">
<div class="flex items-center">
@ -410,7 +407,7 @@ const getCorrectChoiceId = (questionId: number) => {
</div>
</header>
<!-- Main Content Area -->
<!-- พื้นที่เนื้อหาหลัก (Main Content Area) -->
<main class="pt-14 h-screen flex items-center justify-center overflow-y-auto px-4 custom-scrollbar">
<div v-if="isLoading" class="flex flex-col items-center gap-4">
@ -435,9 +432,9 @@ const getCorrectChoiceId = (questionId: number) => {
</div>
<template v-else>
<!-- 1. START SCREEN -->
<!-- 1. หน้าเริ่มต้น (START SCREEN) -->
<div v-if="currentScreen === 'start'" class="w-full max-w-[640px] animate-fade-in py-12">
<!-- ... (Start Screen is unchanged but needs to be here for context) ... -->
<!-- ... (หน้าแรกยังคงเหมือนเดิม แปะไว้เป็น reference) ... -->
<div class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-14 shadow-lg dark:shadow-2xl relative overflow-hidden transition-colors">
<div class="flex justify-center mb-10">
@ -459,7 +456,7 @@ const getCorrectChoiceId = (questionId: number) => {
</p>
</div>
<!-- Instruction Box -->
<!-- กล่องคำแนะนำ (Instruction Box) -->
<div class="bg-slate-50 dark:bg-[#0b121f]/80 p-8 rounded-3xl mb-8 border border-slate-100 dark:border-white/5">
<h3 class="text-[12px] font-black text-slate-500 dark:text-slate-400 mb-6 uppercase tracking-[0.2em] flex items-center gap-2">
{{ $t('quiz.instructionTitle') }}
@ -483,17 +480,16 @@ const getCorrectChoiceId = (questionId: number) => {
</div>
</div>
<!-- 2. TAKING SCREEN -->
<!-- 2. TAKING SCREEN -->
<!-- 2. หน้าทำแบบทดสอบ (TAKING SCREEN) -->
<div v-if="currentScreen === 'taking'" class="w-full max-w-[840px] animate-fade-in py-12">
<div v-if="currentQuestion" class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-12 shadow-xl relative overflow-hidden">
<!-- Progress Bar -->
<!-- แถบความคืบหน้า (Progress Bar) -->
<div class="absolute top-0 left-0 right-0 h-1.5 bg-slate-100 dark:bg-white/5">
<div class="h-full bg-blue-500 transition-all duration-300" :style="{ width: ((currentQuestionIndex + 1) / totalQuestions) * 100 + '%' }"></div>
</div>
<!-- Question Map / Pagination -->
<!-- แผนที่คำถาม / การเปลี่ยนหน้า (Question Map / Pagination) -->
<div class="flex flex-wrap gap-2 mb-8 mt-4">
<button
v-for="(q, idx) in quizData?.questions"
@ -506,12 +502,12 @@ const getCorrectChoiceId = (questionId: number) => {
</button>
</div>
<!-- Question Title -->
<!-- ชื่อคำถาม (Question Title) -->
<h3 class="text-xl md:text-2xl font-bold text-slate-900 dark:text-white mb-10 leading-relaxed">
{{ getLocalizedText(currentQuestion.question) }}
</h3>
<!-- Choices -->
<!-- ส่วนการเลือกคำตอบ (Choices) -->
<div class="flex flex-col gap-4 mb-12">
<button
v-for="choice in currentQuestion.choices"
@ -533,7 +529,7 @@ const getCorrectChoiceId = (questionId: number) => {
<!-- Controls -->
<!-- ปุ่มควบคุมต่างๆ (Controls) -->
<div class="flex justify-between items-center pt-8 border-t border-slate-100 dark:border-white/5">
<button
@click="prevQuestion"
@ -563,7 +559,7 @@ const getCorrectChoiceId = (questionId: number) => {
<!-- 3. RESULT SCREEN -->
<!-- 3. หน้าผลลัพธ์การทำแบบทดสอบ (RESULT SCREEN) -->
<div v-if="currentScreen === 'result'" 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-[40px] p-10 shadow-2xl text-center relative overflow-hidden">
@ -586,7 +582,7 @@ const getCorrectChoiceId = (questionId: number) => {
</p>
</div>
<!-- Score Card -->
<!-- บัตรแสดงคะแนน (Score Card) -->
<div class="bg-slate-50 dark:bg-[#0b121f] rounded-3xl p-6 mb-8 flex items-center justify-around border border-slate-100 dark:border-white/5">
<div class="text-center">
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.scoreLabel') }}</div>
@ -626,7 +622,7 @@ const getCorrectChoiceId = (questionId: number) => {
</div>
</div>
<!-- 4. REVIEW SCREEN -->
<!-- 4. หน้าทบทวนข้อสอบ (REVIEW SCREEN) -->
<div v-if="currentScreen === 'review'" class="w-full max-w-[840px] animate-fade-in py-12 pb-24">
<div class="space-y-6">
<div
@ -652,7 +648,7 @@ const getCorrectChoiceId = (questionId: number) => {
'border-slate-100 dark:border-white/5 opacity-80 dark:opacity-40': userAnswers[question.id] !== choice.id && choice.id !== getCorrectChoiceId(question.id)
}"
>
<!-- Indicator Icon -->
<!-- ไอคอนสถานะ (Indicator Icon) -->
<div
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 font-bold text-sm border-2"
:class="{
@ -668,7 +664,7 @@ const getCorrectChoiceId = (questionId: number) => {
<span class="font-medium text-slate-700 dark:text-slate-300">{{ getLocalizedText(choice.text) }}</span>
<!-- Label Badge -->
<!-- ป้ายแสดงสถานะ (Label Badge) -->
<div v-if="choice.id === getCorrectChoiceId(question.id)" class="ml-auto px-2 py-0.5 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs font-bold rounded uppercase tracking-wider">
{{ $t('quiz.correctLabel', 'Correct') }}
</div>
@ -695,11 +691,11 @@ const getCorrectChoiceId = (questionId: number) => {
</main>
</div> <!-- Close quiz-shell -->
</div> <!-- ปิดส่วนเปลือกข้อสอบ (Close quiz-shell) -->
<!-- Question Navigator Sidebar/Floating (Desktop) - Outside Main Flow -->
<!-- Using QPageSticky properly inside q-page/q-layout context we added -->
<!-- ป๊อปอัปแผนที่คำถาม (เดสก์ท็อป) - อยู่นอกพื้นที่ทำงานปกติ (Question Navigator Sidebar/Floating (Desktop) - Outside Main Flow) -->
<!-- ใช้งาน QPageSticky ให้ถูกที่ (Using QPageSticky properly inside q-page/q-layout context we added) -->
<q-page-sticky
v-if="false"
position="top-right"

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
/**
* @file [id].vue
* @description Dynamic Course Detail Page.
* Displays detailed information about a specific course based on the ID.
* @description หน้าแสดงรายละเอียดคอร์ส (Dynamic Course Detail Page)
* ดึงข้อมูลคอร์สเรียนตาม ID ที่ส่งมาใน URL และมีปุ่มสำหรับចុះทะเบียน
*/
definePageMeta({
@ -114,7 +114,7 @@ useHead({
/>
</div>
<!-- Loading / Error State -->
<!-- ส่วนแสดงผลขณะโหลดข้อมูลหรือเกิดข้อผิดพลาด (Loading / Error State) -->
<div v-else class="text-center py-20">
<div v-if="error" class="text-red-500 mb-4">
<p class="font-bold">เกิดข้อผิดพลาดในการโหลดข้อมูล</p>

View file

@ -1,17 +1,17 @@
<script setup lang="ts">
/**
* @file announcements.vue
* @description Page displaying system and course-related announcements.
* Uses the default layout and requires authentication.
* @description หน้าแสดงประกาศระบบและข่าวสารเกี่ยวกับคอร์สเรียน (Page displaying system and course-related announcements.)
* ใช้เลย์เอาต์เริ่มต้นและต้องตรวจสอบสิทธิ์ (Uses the default layout and requires authentication.)
*/
// Define page metadata: usage of 'default' layout and 'auth' middleware
// ข้อมูล metadata ของหน้า: ใช้เลย์เอาต์ 'default' และระบบตรวจสอบสิทธิ์ 'auth' (Define page metadata: usage of 'default' layout and 'auth' middleware)
definePageMeta({
layout: 'default',
middleware: 'auth'
})
// Set page title for SEO
// กำหนดชื่อหัวหน้าเว็บสำหรับ SEO (Set page title for SEO)
useHead({
title: 'ประกาศ - e-Learning'
})
@ -19,22 +19,22 @@ useHead({
<template>
<div class="page-container">
<!-- Page Header -->
<!-- ส่วนหัวหน้าเว็บ (Page Header) -->
<h1 class="text-3xl font-black mb-10 text-slate-900 dark:text-white">ประกาศ</h1>
<!--
Main Layout: 12-column Grid
- Left Column (span-8): Main announcements content
- Right Column (span-4): Categories/Filter sidebar
โครงสร้างหลัก: กริด 12 คอลัมน์ (Main Layout: 12-column Grid)
- คอลัมน์ซ้าย (span-8): เนื้อหาประกาศหลัก (Left Column: Main announcements content)
- คอลัมน์ขวา (span-4): แถบหมวดหมู่/ตัวกรอง (Right Column: Categories/Filter sidebar)
-->
<div class="grid-12">
<!-- ==========================================
MAIN CONTENT AREA (Left)
พื้นที่เนื้อหาหลัก (ด้านซ้าย) (MAIN CONTENT AREA (Left))
========================================== -->
<div class="col-span-8">
<!-- Feature 1: Critical System Announcement -->
<!-- หน้าที่ 1: ประกาศระบบสำคัญ (Feature 1: Critical System Announcement) -->
<div class="card mb-6">
<div class="flex items-center gap-2 mb-2">
<span class="status-pill status-warning">สำคัญ</span>
@ -42,13 +42,13 @@ useHead({
</div>
<h2 class="text-xl font-bold mb-4 text-slate-900 dark:text-white">แจ้งปิดปรับปรุงระบบ</h2>
<p class="mb-4">เราจะทำการปิดปรับปรุงระบบในวันที่ 25 ธ.ค. เวลา 02:00 - 04:00 น. ขออภัยในความไม่สะดวก</p>
<!-- Attachment Block -->
<!-- ส่วนแนบไฟล์ (Attachment Block) -->
<div class="flex items-center gap-2 p-3 rounded" style="background: var(--neutral-50); border: 1px solid var(--border-color); width: fit-content;">
<span>📎</span> <span class="text-sm font-bold">ตารางการปิดปรับปรุง.pdf</span>
</div>
</div>
<!-- Announcement: UX/UI Course Update -->
<!-- ประกาศ: อัปเดตคอร์ส UX/UI (Announcement: UX/UI Course Update) -->
<div class="card mb-4" style="border-left: 4px solid var(--primary);">
<div class="flex justify-between items-start mb-2" style="flex-wrap: wrap; gap: 8px;">
<div>
@ -61,7 +61,7 @@ useHead({
<NuxtLink to="/browse/discovery" class="text-sm" style="color: var(--primary);">ดูรายละเอียดคอร์ส</NuxtLink>
</div>
<!-- Announcement: Accessibility (WCAG) Material -->
<!-- ประกาศ: เอกสารประกอบการเรียนด้านการเข้าถึงเว็บ (WCAG) (Announcement: Accessibility (WCAG) Material) -->
<div class="card mb-4" style="border-left: 4px solid var(--success);">
<div class="flex justify-between items-start mb-2" style="flex-wrap: wrap; gap: 8px;">
<div>
@ -71,13 +71,13 @@ useHead({
<span class="text-sm text-slate-600 dark:text-slate-400">22 ธ.ค. 2024</span>
</div>
<p class="text-slate-700 dark:text-slate-300 mb-2">เราได้เพิ่มไฟล์ PDF สรุปเกณฑ์ WCAG 2.2 ในส่วนของเอกสารประกอบการเรียนแล้ว...</p>
<!-- Small Attachment -->
<!-- ไฟล์แนบแบบเล็ก (Small Attachment) -->
<div class="flex items-center gap-2 p-2 rounded mt-2" style="background: var(--neutral-50); border: 1px dotted var(--border-color); width: fit-content;">
<span>📄</span> <span class="text-xs">WCAG_2.2_Summary.pdf</span>
</div>
</div>
<!-- Announcement: React Course Update -->
<!-- ประกาศ: อัปเดตคอร์ส React (Announcement: React Course Update) -->
<div class="card mb-4" style="border-left: 4px solid var(--warning);">
<div class="flex justify-between items-start mb-2" style="flex-wrap: wrap; gap: 8px;">
<div>
@ -90,7 +90,7 @@ useHead({
<NuxtLink to="/classroom/learning" class="btn btn-secondary text-sm" style="width: fit-content;">เข้าสู่บทเรียน</NuxtLink>
</div>
<!-- Announcement: General New Course -->
<!-- ประกาศ: คอร์สใหม่ทั่วไป (Announcement: General New Course) -->
<div class="card mb-4">
<div class="flex justify-between items-start mb-2" style="flex-wrap: wrap; gap: 8px;">
<h3 class="font-bold text-slate-900 dark:text-white">คอร์สใหม่: Advanced Python</h3>
@ -100,7 +100,7 @@ useHead({
<a href="#" class="text-sm" style="color: var(--primary);">อ่านเพิ่มเติม</a>
</div>
<!-- Announcement: Platform Update -->
<!-- ประกาศ: อัปเดตแพลตฟอร์ม (Announcement: Platform Update) -->
<div class="card mb-4">
<div class="flex justify-between items-start mb-2" style="flex-wrap: wrap; gap: 8px;">
<h3 class="font-bold text-slate-900 dark:text-white">ยินดีต้อนรับสู่ดีไซน์ใหม่!</h3>
@ -112,24 +112,24 @@ useHead({
</div>
<!-- ==========================================
SIDEBAR (Right)
Category Filters
แถบด้านข้าง (ด้านขวา) (SIDEBAR (Right))
ตัวกรองหมวดหมู่ (Category Filters)
========================================== -->
<div class="col-span-4">
<div class="card">
<h3 class="font-bold mb-4 text-slate-900 dark:text-white">หมวดหมู่</h3>
<ul class="flex flex-col gap-2">
<!-- Filter Option: All -->
<!-- ตัวเลือกตัวกรอง: ทั้งหมด (Filter Option: All) -->
<li class="flex justify-between items-center p-2 rounded cursor-pointer" style="background: var(--neutral-50);">
<span>ทั้งหมด</span>
<span class="text-muted">15</span>
</li>
<!-- Filter Option: System Updates -->
<!-- ตัวเลือกตัวกรอง: อัปเดตระบบ (Filter Option: System Updates) -->
<li class="flex justify-between items-center p-2 rounded cursor-pointer">
<span>อัปเดตระบบ</span>
<span class="text-muted">3</span>
</li>
<!-- Filter Option: Course News -->
<!-- ตัวเลือกตัวกรอง: ข่าวสารคอร์ส (Filter Option: Course News) -->
<li class="flex justify-between items-center p-2 rounded cursor-pointer">
<span>ข่าวสารคอร์ส</span>
<span class="text-muted">11</span>

View file

@ -126,7 +126,7 @@ const navigateToCategory = (catName: string) => {
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen font-inter p-4 md:p-8 transition-colors duration-300">
<div class="max-w-[1400px] mx-auto grid grid-cols-1 xl:grid-cols-3 gap-8">
<!-- Left Column (Main Content) -->
<!-- คอลัมน์ซ้าย (เนื้อหาหลัก) -->
<div class="xl:col-span-2 space-y-6">
<!-- ป้ายต้อนรับ (Welcome Banner) -->
@ -179,7 +179,7 @@ const navigateToCategory = (catName: string) => {
</div>
</div>
<!-- Continue Learning (เรียนต่อจากครั้งก่อน) -->
<!-- ส่วนเรียนต่อจากครั้งก่อน (Continue Learning) -->
<div class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-sm border border-slate-100 dark:border-slate-800 transition-colors">
<div class="flex items-center justify-between mb-6">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('dashboard.continueLearningTitle') }}</h2>
@ -195,7 +195,7 @@ const navigateToCategory = (catName: string) => {
<div class="flex-1 w-full flex flex-col">
<div class="flex items-center justify-between mb-3">
<!-- Category Badge -->
<!-- ป้ายบอกหมวดหมู่ (Category Badge) -->
<div v-if="heroCourse.category">
<span class="bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] dark:text-blue-400 px-3 py-1 rounded-full text-[11px] font-bold tracking-wide">{{ getLocalizedText(heroCourse.category) }}</span>
</div>
@ -208,7 +208,7 @@ const navigateToCategory = (catName: string) => {
<h3 class="text-2xl font-bold text-slate-900 dark:text-white mb-2 leading-snug line-clamp-2">
{{ getLocalizedText(heroCourse.title) || 'Advanced UI/UX Design มาสเตอร์คลาส' }}
</h3>
<!-- Removed Lesson Title/Number as per request -->
<!-- ไม่แสดงเลขบทเรียนตามที่ได้รับคำขอมา (Removed Lesson Title/Number as per request) -->
<div class="mb-6 mt-4">
<div class="flex justify-between text-[13px] font-bold mb-2">
@ -245,11 +245,11 @@ const navigateToCategory = (catName: string) => {
</div>
</div>
<!-- Right Column (Sidebar/Profile Content) -->
<!-- คอลัมน์ขวา (แถบข้อมูลโปรไฟล์และคอร์สแนะนำ) -->
<div class="xl:col-span-1 space-y-6">
<!-- Profile Widget -->
<!-- วิดเจ็ตโปรไฟล์ผู้ใช้ -->
<div class="bg-white dark:!bg-slate-900 rounded-[2rem] p-8 shadow-sm border border-slate-100 dark:border-slate-800 text-center flex flex-col items-center relative overflow-hidden transition-colors">
<!-- decorative bg -->
<!-- พื้นหลังตกแต่ง (decorative bg) -->
<div class="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-[#F8FAFC] to-white dark:from-slate-800 dark:to-slate-900"></div>
<div class="relative z-10 w-24 h-24 rounded-full bg-white dark:bg-slate-800 mb-4 shadow-md flex items-center justify-center">
@ -280,18 +280,18 @@ const navigateToCategory = (catName: string) => {
</div>
</div>
<!-- Recommended Courses Widget -->
<!-- วิดเจ็ตคอร์สแนะนำ -->
<div v-if="recommendedCourses.length > 0" class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 shadow-sm border border-slate-100 dark:border-slate-800 transition-colors">
<h2 class="text-[1.1rem] font-bold text-slate-900 dark:text-white mb-5 tracking-tight flex items-center justify-between">
{{ $t('dashboard.recommendedCourses') }}
</h2>
<div class="flex flex-col gap-5">
<div v-for="course in recommendedCourses.slice(0, 3)" :key="course.id" class="flex gap-4 group cursor-pointer transition-all" @click="navigateTo(`/browse/discovery?course_id=${course.id}`)">
<!-- Thumbnail -->
<!-- รูปหน้าปก (Thumbnail) -->
<div class="w-24 h-[68px] rounded-xl overflow-hidden bg-slate-100 shrink-0 relative shadow-sm">
<img :src="course.image" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div>
<!-- Info -->
<!-- ข้อมูลคอร์ส (Info) -->
<div class="flex-1 flex flex-col justify-center min-w-0">
<h3 class="font-bold text-[13px] text-slate-900 dark:text-white leading-snug line-clamp-2 mb-1.5 group-hover:text-[#3B6BE8] transition-colors pr-1">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center justify-between mt-auto">

View file

@ -67,22 +67,22 @@ const showPassword = reactive({
})
// Rules have been moved to components
// กฎต่างๆ ถูกย้ายไปที่คอมโพเนนต์แล้ว (Rules have been moved to components)
const fileInput = ref<HTMLInputElement | null>(null) // Used in view mode (outside component)
const fileInput = ref<HTMLInputElement | null>(null) // ใช้ในโหมดมุมมอง (อยู่นอกคอมโพเนนต์) (Used in view mode (outside component))
const toggleEdit = (edit: boolean) => {
isEditing.value = edit
}
// Updated to accept File object directly (or Event for view mode compatibility if needed)
// อัปเดตให้รับออบเจ็กต์ File ได้โดยตรง (หรือ Event สำหรับความเข้ากันได้ของโหมดมุมมองหากจำเป็น) (Updated to accept File object directly (or Event for view mode compatibility if needed))
const handleFileUpload = async (fileOrEvent: File | Event) => {
let file: File | null = null
if (fileOrEvent instanceof File) {
file = fileOrEvent
} else {
// Fallback for native input change event
// การทำงานสำรองสำหรับอีเวนต์ input change แบบเนทีฟ (Fallback for native input change event)
const target = (fileOrEvent as Event).target as HTMLInputElement
if (target.files && target.files[0]) {
file = target.files[0]
@ -112,7 +112,7 @@ const handleFileUpload = async (fileOrEvent: File | Event) => {
}
}
// Trigger upload for VIEW mode avatar click
// เรียกการอัปโหลดเมื่อคลิกที่รูปโปรไฟล์ในโหมด View (Trigger upload for VIEW mode avatar click)
const triggerUpload = () => {
fileInput.value?.click()
}
@ -191,7 +191,7 @@ const handleUpdatePassword = async () => {
isPasswordSaving.value = false
}
// Watch for changes in global user state (e.g. after avatar upload or profile update)
// เฝ้าดูการเปลี่ยนแปลงในสถานะผู้ใช้ส่วนกลาง (เช่น หลังจากอัปโหลดรูปโปรไฟล์หรืออัปเดตข้อมูลส่วนตัว) (Watch for changes in global user state (e.g. after avatar upload or profile update))
watch(() => currentUser.value, (newUser) => {
if (newUser) {
userData.value.photoURL = newUser.photoURL || ''
@ -220,7 +220,7 @@ onMounted(async () => {
<q-spinner size="3rem" color="primary" />
</div>
<!-- MAIN PROFILE SETTINGS -->
<!-- การตั้งค่าโปรไฟล์หลัก (MAIN PROFILE SETTINGS) -->
<div v-else class="max-w-5xl mx-auto pb-20 fade-in pt-4">
<!-- บัตรข้อมูลโปรไฟล์ (Profile Card) -->
@ -255,7 +255,7 @@ onMounted(async () => {
</div>
</div>
<!-- Form Inputs (2 Column Grid) -->
<!-- ฟิลด์ข้อมูลฟอร์ม (แบ่งเป็น 2 คอลัมน์) (Form Inputs (2 Column Grid)) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-4">
<div class="md:col-span-2 relative">
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.prefix') }}</label>
@ -300,7 +300,7 @@ onMounted(async () => {
</div>
<!-- Footer Buttons -->
<!-- ปุ่มกดยืนยันต่างๆ (Footer Buttons) -->
<div class="px-6 sm:px-8 py-5 border-t border-slate-200 dark:border-slate-800 flex flex-col sm:flex-row justify-center sm:justify-end gap-3 items-center bg-white dark:!bg-slate-900">
<button class="w-full sm:w-auto text-[13px] font-bold text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white px-4 py-2 transition order-2 sm:order-1" @click="fetchUserProfile(true)">{{ $t('common.cancel') }}</button>
<button @click="handleUpdateProfile" :disabled="isProfileSaving" class="w-full sm:w-auto bg-[#3B6BE8] hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg text-[13px] font-bold transition shadow-sm disabled:opacity-50 order-1 sm:order-2">
@ -310,7 +310,7 @@ onMounted(async () => {
</div>
</div>
<!-- Security Card -->
<!-- การ์ดความปลอดภัย (Security Card) -->
<div class="bg-white dark:!bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm overflow-hidden">
<div class="p-8 border-b border-slate-200 dark:border-slate-800">
<h2 class="text-xl font-bold text-slate-900 dark:text-white">{{ $t('profile.security') }}</h2>
@ -337,7 +337,7 @@ onMounted(async () => {
</div>
<!-- Password Modal -->
<!-- โมดอลเปลี่ยนรหัสผ่าน (Password Modal) -->
<q-dialog v-model="showPasswordModal">
<q-card class="w-full max-w-md rounded-2xl p-2 dark:bg-slate-900 shadow-xl">
<q-form @submit="handleUpdatePassword">

View file

@ -22,7 +22,7 @@ const { user } = useAuth()
const categoryCards = CATEGORY_CARDS
const whyChooseUs = WHY_CHOOSE_US
//ระดับความยาก
// ระดับความยาก (Level)
const levelModel = ref('ระดับทั้งหมด')
const levelOptions = ['ระดับทั้งหมด','ระดับเริ่มต้น', 'ระดับกลาง', 'ระดับสูง']
@ -95,22 +95,22 @@ onMounted(() => {
<!-- Section 1: Hero Section -->
<section class="container mx-auto py-24 md:py-24 lg:py-28 px-6 lg:px-12 pb-16">
<div class="flex flex-col lg:flex-row items-center gap-10 lg:gap-10 justify-between animate-fade-in">
<!-- Left Content -->
<!-- ด้านซ้าย: ข้อความและปุ่มกด (Left Content) -->
<div class="flex flex-col items-start gap-6 flex-1 max-w-2xl ">
<!-- Heading -->
<!-- หัวข้อหลัก (Heading) -->
<h1 class="text-4xl sm:text-5xl lg:text-[55px] font-bold leading-tight lg:leading-[66px] slide-up" style="animation-delay: 0.2s;">
<span class="text-slate-900">ขยายขอบเขตความรู้ของคุณ</span><br>
<span class="text-blue-600">ด้วยการเรียนรู้ออนไลน์</span>
</h1>
<!-- Subtitle -->
<!-- คำอธิบายรอง (Subtitle) -->
<p class="text-slate-500 text-lg sm:text-xl leading-relaxed slide-up" style="animation-delay: 0.3s;">
จุดประกายความรู้ของคุณ และเริ่มต้นอัปสกิลกับผู้เชี่ยวชาญ
ในอุตสาหกรรมที่มีความรู้รอบด้านหลากหลายในหลายสาขา
เรียนได้ทุกที่ ทุกเวลา
</p>
<!-- Buttons -->
<!-- ปุ่มกดต่างๆ (Buttons) -->
<div class=" w-full flex flex-col sm:flex-row items-center gap-4 pt-5 slide-up" style="animation-delay: 0.4s;">
<q-btn
unelevated
@ -134,7 +134,7 @@ onMounted(() => {
</div>
</div>
<!-- Right - Hero Image -->
<!-- ด้านขวา: รูปภาพฮีโร่ (Right - Hero Image) -->
<div class="flex-1 w-full max-w-lg md:max-w-md lg:max-w-xl pl-0 py-10">
<div class="relative rounded-2xl overflow-hidden shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] aspect-square">
<img
@ -144,7 +144,7 @@ onMounted(() => {
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
<!-- Course Card Overlay -->
<!-- ส่วนทับซ้อนสำหรับทำโมเดลการ์ดคอร์ส (Course Card Overlay) -->
<!-- <div class="absolute bottom-5 left-5 right-5">
<div class="bg-white/85 backdrop-blur-sm border border-white/20 rounded-3xl px-6 py-5">
<div class="flex items-center gap-4">
@ -170,7 +170,7 @@ onMounted(() => {
<!-- Section 2: ทำไมต้องเลือกแพลตฟอร์มของเรา -->
<section class="pt-20 pb-14 bg-white relative flex-col">
<div class="container mx-auto px-6 lg:px-12">
<!-- Heading -->
<!-- หัวข้อหลัก (Heading) -->
<div class="text-center mb-16 slide-up">
<h2 class="text-4xl md:text-[2.4rem] font-bold text-slate-900 mb-6">
ทำไมต้องเลือกแพลตฟอร์มของเรา?
@ -180,7 +180,7 @@ onMounted(() => {
</p>
</div>
<!-- Horizontal Cards -->
<!-- โซนแนะนำแบบการ์ดแนวนอน (Horizontal Cards) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div v-for="(item, i) in whyChooseUs" :key="i"
class="slide-up p-10 rounded-2xl bg-[#F8FAFC] border border-[#f1f2f9] hover:border-[#2463eb61] hover:bg-white transition-all duration-500 group"
@ -203,25 +203,25 @@ onMounted(() => {
<!-- Section 3: เลือกเรียนตามเรื่องที่คุณสนใจ -->
<section class="py-20 md:py-24 bg-white">
<div class="container mx-auto px-6 lg:px-12">
<!-- Heading -->
<!-- หัวข้อ (Heading) -->
<div class="mb-12 slide-up">
<h2 class="text-[1.4rem] text-3xl md:text-4xl font-semibold text-slate-900 px-4">
เลือกเรียนตามเรื่องที่คุณสนใจ
</h2>
</div>
<!-- Horizontal Cards -->
<!-- โซนหมวดหมู่แบบการ์ดแนวนอน (Horizontal Cards) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 justify-center gap-6 px-4">
<div v-for="(card, i) in categoryCards" :key="i"
class="cursor-pointer bg-white rounded-3xl p-6 border border-slate-200/80 shadow-[0px_1px_2px_0px_rgba(0,0,0,0.05)] hover:shadow-2xl hover:shadow-blue-600/5 hover:-translate-y-1 hover:border-[#2463eb61] transition-all duration-500 flex items-center gap-5"
@click="goBrowse(card.slug)"
>
<!-- Icon Box -->
<!-- กล่องไอคอน (Icon Box) -->
<div class="flex-shrink-0 w-16 h-16 rounded-2xl flex items-center justify-center bg-slate-50 group-hover:scale-110 transition-transform duration-500">
<q-icon :name="card.icon" size="35px" class="text-blue-600" />
</div>
<!-- Content -->
<!-- เนื้อหาข้อความ (Content) -->
<div class="flex-grow pr-2">
<h3 class="text-lg md:text-xl font-bold text-slate-900 mb-1 group-hover:text-blue-600 transition-colors leading-tight">
{{ card.title }}
@ -231,7 +231,7 @@ onMounted(() => {
</p>
</div>
<!-- Arrow -->
<!-- ลูกศรชี้ขวา (Arrow) -->
<div class="gt-xs flex-shrink-0 text-slate-300 group-hover:text-blue-600 transition-colors transform group-hover:translate-x-1 duration-300">
<q-icon name="chevron_right" size="24px" />
</div>
@ -243,7 +243,7 @@ onMounted(() => {
<!-- Section 4: "คอร์สออนไลน์" -->
<section class="py-12 md:py-24 bg-slate-50">
<div class="container mx-auto px-6 lg:px-12">
<!-- Heading -->
<!-- หัวข้อหลักและลิงก์เพิ่มเติม (Heading) -->
<div class="flex flex-col md:flex-row items-start md:items-end justify-between mb-5 gap-8">
<div class="slide-up">
<h2 class="text-4xl md:text-[2.4rem] font-bold text-slate-900 mb-4">คอร์สออนไลน์</h2>
@ -254,9 +254,9 @@ onMounted(() => {
</NuxtLink>
</div>
<!-- Filters Row -->
<!-- แถวตัวกรองคอร์ส (Filters Row) -->
<div class="flex items-center gap-2 mb-8 overflow-x-auto no-scrollbar slide-up justify-between">
<!-- Category Filters -->
<!-- ตัวกรองหมวดหมู่คอร์ส (Category Filters) -->
<div class="flex items-center gap-2">
<button
class="py-2 px-5 rounded-full font-medium text-lg transition-all whitespace-nowrap border-2"
@ -296,7 +296,7 @@ onMounted(() => {
</div> -->
</div>
<!-- Courses Carousel -->
<!-- ระบบเลื่อนสไลด์ดูคอร์ส (Courses Carousel) -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div v-for="i in 4" :key="i" class="bg-white rounded-2xl h-[450px] animate-pulse" />
</div>
@ -326,7 +326,7 @@ onMounted(() => {
class="flex flex-col flex-1 min-w-0 rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden hover:shadow-lg hover:-translate-y-1 transition-all duration-300 cursor-pointer"
@click="navigateTo(`/course/${course.id}`)"
>
<!-- Image-->
<!-- รูปภาพหน้าปกคอร์ส (Image) -->
<div class="relative flex-shrink-0">
<img
v-if="course.thumbnail_url"
@ -339,23 +339,23 @@ onMounted(() => {
</div>
</div>
<!-- Content -->
<!-- เนื้อหาของคอร์ส (Content) -->
<div class="flex flex-col flex-1 p-6">
<!-- Title -->
<!-- ชื่อคอร์ส (Title) -->
<h3 class="text-[#0F172A] font-semibold text-lg leading-snug mb-2 line-clamp-2">
{{ getLocalizedText(course.title) }}
</h3>
<!-- Description -->
<!-- รายละเอียดแบบย่อ (Description) -->
<p class="text-slate-500 text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
{{ getLocalizedText(course.description) }}
</p>
<!-- Price + Button -->
<!-- ส่วนแถบราคาและปุ่มกด (Price + Button) -->
<div class="flex items-center justify-between pt-6 border-t border-slate-100 gap-2">
<div class="flex flex-col">
<span v-if="course.price > 0" class="text-[#0F172A] font-bold text-xl">
@ -378,7 +378,7 @@ onMounted(() => {
</q-carousel-slide>
</q-carousel>
<!-- Custom Carousel Navigation -->
<!-- ระบบนำทางสไลด์แบบกำหนดเอง (Custom Carousel Navigation) -->
<button
v-if="courseChunks.length > 1"
class="absolute -left-4 md:-left-12 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full bg-white shadow-xl border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all hover:scale-110"

View file

@ -2,7 +2,7 @@
<div class="min-h-screen bg-gray-50 p-4 md:p-8">
<div class="max-w-4xl mx-auto">
<!-- Header / Title -->
<!-- ส่วนหัว / ชื่อเรื่อง (Header / Title) -->
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-800">Quiz Runner</h1>
@ -16,7 +16,7 @@
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Sidebar: Question Navigator -->
<!-- แถบด้านข้าง: ตัวนำทางคำถาม (Sidebar: Question Navigator) -->
<div class="lg:col-span-3 order-2 lg:order-1">
<QCard class="bg-white shadow-sm sticky top-4">
<QCardSection>
@ -33,7 +33,7 @@
</button>
</div>
<!-- Legend -->
<!-- คำอธิบายสัญลักษณ์ (Legend) -->
<div class="mt-6 space-y-2 text-xs text-gray-600">
<div class="flex items-center gap-2"><div class="w-3 h-3 rounded-full bg-blue-500"></div> Current</div>
<div class="flex items-center gap-2"><div class="w-3 h-3 rounded-full bg-green-500"></div> Completed</div>
@ -44,11 +44,11 @@
</QCard>
</div>
<!-- Main Content: Question -->
<!-- เนื้อหาหลัก: คำถาม (Main Content: Question) -->
<div class="lg:col-span-9 order-1 lg:order-2">
<QCard v-if="store.currentQuestion" class="bg-white shadow-md min-h-[400px] flex flex-col">
<!-- Question Header -->
<!-- ส่วนหัวคำถาม (Question Header) -->
<QCardSection class="bg-gray-50 border-b border-gray-100 py-4">
<div class="flex justify-between items-start">
<div>
@ -57,18 +57,18 @@
</QBadge>
<h2 class="text-xl font-medium text-gray-800">
<span class="text-gray-400 mr-2">{{ store.currentQuestionIndex + 1 }}.</span>
{{ store.currentQuestion.title }}
{{ getLocalizedString(store.currentQuestion.question) }}
</h2>
</div>
</div>
</QCardSection>
<!-- Question Body -->
<!-- ส่วนเนื้อหาคำถาม (Question Body) -->
<QCardSection class="flex-grow py-8 px-6">
<!-- Single Choice -->
<!-- เลือกคำตอบเดียว (Single Choice) -->
<div v-if="store.currentQuestion.type === 'single'">
<div v-for="opt in store.currentQuestion.options" :key="opt.id"
<div v-for="opt in store.currentQuestion.choices" :key="opt.id"
class="mb-3 p-3 border rounded-lg hover:bg-blue-50 cursor-pointer transition-colors"
:class="{ 'border-blue-500 bg-blue-50': currentVal === opt.id, 'border-gray-200': currentVal !== opt.id }"
@click="handleInput(opt.id)">
@ -77,14 +77,14 @@
:class="{ 'border-blue-500': currentVal === opt.id, 'border-gray-300': currentVal !== opt.id }">
<div v-if="currentVal === opt.id" class="w-2.5 h-2.5 rounded-full bg-blue-500"></div>
</div>
<span class="text-gray-700">{{ opt.label }}</span>
<span class="text-gray-700">{{ getLocalizedString(opt.text) }}</span>
</div>
</div>
</div>
<!-- Multiple Choice -->
<!-- เลือกหลายคำตอบ (Multiple Choice) -->
<div v-else-if="store.currentQuestion.type === 'multiple'">
<div v-for="opt in store.currentQuestion.options" :key="opt.id"
<div v-for="opt in store.currentQuestion.choices" :key="opt.id"
class="mb-3 p-3 border rounded-lg hover:bg-blue-50 cursor-pointer transition-colors"
:class="{ 'border-blue-500 bg-blue-50': isSelected(opt.id), 'border-gray-200': !isSelected(opt.id) }"
@click="toggleSelection(opt.id)">
@ -93,12 +93,12 @@
:class="{ 'border-blue-500 bg-blue-500': isSelected(opt.id), 'border-gray-300': !isSelected(opt.id) }">
<QIcon v-if="isSelected(opt.id)" name="check" class="text-white text-xs" />
</div>
<span class="text-gray-700">{{ opt.label }}</span>
<span class="text-gray-700">{{ getLocalizedString(opt.text) }}</span>
</div>
</div>
</div>
<!-- Text Input -->
<!-- พิมพ์ข้อความ (Text Input) -->
<div v-else-if="store.currentQuestion.type === 'text'">
<QInput
v-model="textModel"
@ -113,7 +113,7 @@
</QCardSection>
<!-- Error Banner -->
<!-- แบนเนอร์แสดงข้อผิดพลาด (Error Banner) -->
<QBanner v-if="store.lastError" class="bg-red-50 text-red-600 px-6 py-2 border-t border-red-100">
<template v-slot:avatar>
<QIcon name="warning" color="red" />
@ -121,7 +121,7 @@
{{ store.lastError }}
</QBanner>
<!-- Actions Footer -->
<!-- ส่วนท้ายปุ่มกดต่างๆ (Actions Footer) -->
<QCardSection class="border-t border-gray-100 bg-gray-50 p-4 flex flex-wrap gap-4 items-center justify-between">
<QBtn
@ -140,7 +140,7 @@
flat
color="orange-8"
label="Skip for now"
@click="store.skipQuestion()"
@click="store.nextQuestion()"
no-caps
/>
@ -178,11 +178,11 @@
<script setup lang="ts">
import { computed, ref, onMounted, watch, reactive } from 'vue';
import { useRoute } from 'vue-router';
// Composable is auto-imported in Nuxt
// คอมโพสิเบิลถูกนำเข้าอัตโนมัติใน Nuxt (Composable is auto-imported in Nuxt)
// import { useQuizRunner } from '@/composables/useQuizRunner';
const route = useRoute();
// Wrap in reactive to unwrap refs, mimicking Pinia store behavior for template
// ห่อหุ้มใน reactive เพื่อแกะค่า refs ทำให้เหมือนพฤติกรรม Pinia store สำหรับ template (Wrap in reactive to unwrap refs, mimicking Pinia store behavior for template)
const store = reactive(useQuizRunner());
onMounted(() => {
@ -190,7 +190,16 @@ onMounted(() => {
store.initQuiz(quizId);
});
// -- Helpers for Input Handling --
// -- ตัวช่วยสำหรับการจัดการการป้อนข้อมูล (Helpers for Input Handling) --
// ฟังก์ชันช่วยเหลือสำหรับแปลภาษา (Helper to safely format text)
const getLocalizedString = (val: any): string => {
if (typeof val === 'string') return val;
if (val && typeof val === 'object') {
return val.th || val.en || String(val);
}
return String(val || '');
}
const currentVal = computed(() => {
return store.currentAnswer?.value;
@ -200,23 +209,23 @@ const isSaved = computed(() => {
return store.currentAnswer?.is_saved;
});
// Single Choice
function handleInput(val: string) {
// เลือกคำตอบเดียว (Single Choice)
function handleInput(val: number) {
store.updateAnswer(val);
}
// Text Choice
// พิมพ์ข้อความ (Text Choice)
const textModel = ref('');
// Watch for question changes to reset text model
// จับตาดูการเปลี่ยนแปลงคำถามเพื่อล้างค่า (Watch for question changes to reset text model)
watch(
() => store.currentQuestionIndex,
() => {
if (store.currentQuestion?.type === 'text') {
textModel.value = (store.currentAnswer?.value as string) || '';
}
// Clear error when changing question
// ล้างข้อผิดพลาดเมื่อเปลี่ยนคำถาม (Clear error when changing question)
store.lastError = null;
// Scroll to top
// เลื่อนหน้าจอขึ้นบนสุด (Scroll to top)
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
@ -224,7 +233,7 @@ watch(
{ immediate: true }
);
// Watch for error to scroll to error/field
// จับตาดูข้อผิดพลาดเพื่อเลื่อนหน้าจอไปยังฟิลด์นั้น (Watch for error to scroll to error/field)
watch(
() => store.lastError,
(newVal) => {
@ -245,8 +254,8 @@ function handleTextInput(val: string | number | null) {
store.updateAnswer(val as string);
}
// Multiple Choice
function isSelected(id: string) {
// เลือกหลายคำตอบ (Multiple Choice)
function isSelected(id: number) {
const val = store.currentAnswer?.value;
if (Array.isArray(val)) {
return val.includes(id);
@ -254,9 +263,9 @@ function isSelected(id: string) {
return false;
}
function toggleSelection(id: string) {
function toggleSelection(id: number) {
const val = store.currentAnswer?.value;
let currentArr: string[] = [];
let currentArr: number[] = [];
if (Array.isArray(val)) {
currentArr = [...val];
}
@ -270,10 +279,10 @@ function toggleSelection(id: string) {
store.updateAnswer(currentArr);
}
// -- Helpers for Styling --
// -- ตัวช่วยสำหรับการจัดแต่งทรง (Helpers for Styling) --
function getIndicatorClass(index: number, qId: number) {
// 1. Current = Blue
// 1. ปัจจุบัน = สีน้ำเงิน (Current = Blue)
if (index === store.currentQuestionIndex) {
return 'bg-blue-500 text-white border-blue-600';
}
@ -286,7 +295,8 @@ function getIndicatorClass(index: number, qId: number) {
case 'skipped':
return 'bg-orange-500 text-white border-orange-600';
case 'in_progress':
// If it's in_progress but NOT current (should be rare/impossible with strict logic, but handled)
// กรณีที่เป็น in_progress แต่ไม่ใช่คำถามปัจจุบัน (จริงๆ เกิดยากตามตรรกะที่เข้มงวด แต่เผื่อไว้ก่อน)
// (If it's in_progress but NOT current (should be rare/impossible with strict logic, but handled))
return 'bg-blue-200 text-blue-800 border-blue-300';
case 'not_started':
default:
@ -297,5 +307,5 @@ function getIndicatorClass(index: number, qId: number) {
</script>
<style scoped>
/* Optional: Transitions */
/* ส่วนเสริม: ทรานสิชั่น (Optional: Transitions) */
</style>

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
/**
* @file reset-password.vue
* @description Reset Password Page.
* Allows user to set a new password after verifying their email link (simulated).
* @description หน้าตั้งรหัสผ่านใหม่ (Reset Password Page.
* อนุญาตให้ผู้ใช้ตั้งรหัสผ่านใหม่หลังจากยืนยันลิงก์อีเมล)
*/
definePageMeta({
@ -44,9 +44,9 @@ const handlePasswordInput = (field: keyof typeof resetForm, val: string) => {
resetForm[field] = val
if (/[\u0E00-\u0E7F]/.test(val)) {
if (field === 'password') errors.value.password = 'ห้ามใส่ภาษาไทย'
// We don't necessarily need to flag confirmPassword individually if it just needs to match, but let's be consistent if we want
// ไม่จำเป็นต้องแจ้งเตือน confirmPassword แยกต่างหากถ้ามันแค่ต้องตรงกัน แต่เพื่อให้สอดคล้องกันเราก็ควรทำ (We don't necessarily need to flag confirmPassword individually if it just needs to match, but let's be consistent if we want)
} else {
// Clear error if it was "Thai characters"
// ลบข้อผิดพลาดถ้าเป็น "ห้ามใส่ภาษาไทย" (Clear error if it was "Thai characters")
if (field === 'password' && errors.value.password === 'ห้ามใส่ภาษาไทย') {
clearFieldError('password')
}
@ -63,7 +63,7 @@ onMounted(() => {
const resetPassword = async () => {
if (!validate(resetForm, resetRules)) return
// Extract token from query
// ดึงโทเคนจาก URL query (Extract token from query)
const token = route.query.token as string
if (!token) {
@ -92,7 +92,7 @@ const resetPassword = async () => {
<template>
<div class="relative min-h-screen w-full flex items-center justify-center p-4 overflow-hidden bg-slate-50 transition-colors">
<!-- ==========================================
BACKGROUND EFFECTS (Light Mode Only)
เอฟเฟกต์พื้นหลัง (แสดงเฉพาะโหมดสว่าง) (BACKGROUND EFFECTS (Light Mode Only))
========================================== -->
<div class="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<div class="absolute inset-0 bg-gradient-to-br from-white via-slate-50 to-blue-50/50"></div>
@ -101,11 +101,11 @@ const resetPassword = async () => {
</div>
<!-- ==========================================
RESET PASSWORD CARD
การ์ดตั้งรหัสผ่านใหม่ (RESET PASSWORD CARD)
========================================== -->
<div class="w-full max-w-[460px] relative z-10 slide-up">
<!-- Header / Logo -->
<!-- หัวข้อ / โลโก้ (Header / Logo) -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-tr from-blue-600 to-indigo-600 text-white shadow-lg shadow-blue-600/20 mb-6">
<span class="font-black text-2xl">E</span>
@ -116,10 +116,10 @@ const resetPassword = async () => {
<div class="bg-white rounded-[2rem] p-8 md:p-10 shadow-xl shadow-slate-200/50 border border-slate-100 relative overflow-hidden">
<!-- Form -->
<!-- ฟอร์ม (Form) -->
<form @submit.prevent="resetPassword" class="flex flex-col gap-6">
<!-- New Password -->
<!-- รหัสผ่านใหม่ (New Password) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหัสผ่านใหม่ <span class="text-red-500">*</span></label>
<div class="relative group">
@ -145,7 +145,7 @@ const resetPassword = async () => {
<span v-if="errors.password" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.password }}</span>
</div>
<!-- Confirm Password -->
<!-- ยืนยันรหัสผ่านใหม่ (Confirm Password) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">ยืนยันรหัสผ่านใหม่ <span class="text-red-500">*</span></label>
<div class="relative group">
@ -171,7 +171,7 @@ const resetPassword = async () => {
<span v-if="errors.confirmPassword" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.confirmPassword }}</span>
</div>
<!-- Submit Button -->
<!-- ปุ่มยืนยัน (Submit Button) -->
<button
type="submit"
:disabled="isLoading"
@ -183,7 +183,7 @@ const resetPassword = async () => {
</form>
</div>
<!-- Back Link -->
<!-- ลิงก์ย้อนกลับ (Back Link) -->
<div class="mt-8 text-center text-slate-500">
<NuxtLink to="/auth/login" class="inline-flex items-center gap-2 text-sm font-medium hover:text-slate-800 transition-colors group px-4 py-2 rounded-lg hover:bg-white/50">
<span class="group-hover:-translate-x-1 transition-transform">←</span> กลับไปหน้าเข้าสู่ระบบ

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
/**
* @file verify-email.vue
* @description Page for handling email verification process.
* Displays loading state while processing token, then shows success or error message.
* @description หน้าสำหรับกระบวนการยืนยันอีเมล (Page for handling email verification process.
* แสดงสถานะกำลังโหลดระหว่างประมวลผลโทเคน จากนั้นแสดงข้อความสำเร็จหรือข้อผิดพลาด)
*/
definePageMeta({
@ -28,7 +28,7 @@ onMounted(async () => {
return
}
// Call verify API
// เรียกใช้ API เพื่อยืนยัน (Call verify API)
const result = await verifyEmail(token)
isLoading.value = false
@ -39,7 +39,7 @@ onMounted(async () => {
isSuccess.value = false
if (result.code === 400) {
errorMessage.value = t('profile.emailAlreadyVerified')
// If already verified, show success state with specific message
// หากยืนยันแล้วให้แสดงสถานะสำเร็จพร้อมด้วยข้อความเฉพาะ (If already verified, show success state with specific message)
isSuccess.value = true
} else if (result.code === 401) {
errorMessage.value = t('auth.tokenExpired')
@ -58,7 +58,7 @@ const navigateToHome = () => {
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-slate-50 dark:bg-[#0f172a]">
<div class="auth-card max-w-md w-full space-y-8 p-8 rounded-2xl text-center">
<!-- Loading State -->
<!-- สถานะกำลังโหลด (Loading State) -->
<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">
@ -66,7 +66,7 @@ const navigateToHome = () => {
</h2>
</div>
<!-- Success State -->
<!-- สถานะสำเร็จ (Success State) -->
<div v-else-if="isSuccess" class="flex flex-col items-center animate-bounce-in">
<div class="w-24 h-24 rounded-full bg-green-500 flex items-center justify-center mb-10 shadow-lg shadow-green-500/20">
<q-icon name="check" class="text-5xl text-white font-black" />
@ -89,7 +89,7 @@ const navigateToHome = () => {
/>
</div>
<!-- Error State -->
<!-- สถานะข้อผิดพลาด (Error State) -->
<div v-else class="flex flex-col items-center animate-shake">
<div class="w-24 h-24 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-6">
<q-icon name="error" class="text-6xl text-red-500" />
@ -126,11 +126,15 @@ const navigateToHome = () => {
}
.auth-card {
@apply bg-white border-slate-100 shadow-xl;
background-color: white;
border-color: #f1f5f9;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border-width: 1px;
}
.dark .auth-card {
@apply bg-[#1e293b] border-white/5 shadow-none;
:global(.dark) .auth-card {
background-color: #1e293b;
border-color: rgba(255, 255, 255, 0.05);
box-shadow: none;
}
@keyframes bounceIn {

View file

@ -3,7 +3,7 @@
เอกสารฉบับนี้สรุปรายละเอียดทางเทคนิค โครงสร้างโค้ด และกลไกการทำงานของระบบ **Frontend-Learner (ฝั่งผู้เรียน)**
ใช้เป็นคู่มือสำหรับการพัฒนา บำรุงรักษา และขยายระบบต่อไป
> อัปเดตล่าสุด: ปลายเดือนกุมภาพันธ์ 2026
> อัปเดตล่าสุด: 27 กุมภาพันธ์ 2026 (อัปเดตคอมเมนต์ภาษาไทย & แก้ไข TypeScript)
---
@ -134,5 +134,47 @@
- **Standard Fonts:** ใช้ชุด Font Prompt ผ่านตัวแปร `--font-main` เสมอ
- **API Integrity:** ตรวจสอบข้อมูลผ่าน Interface ใน `@/types` ก่อนการใช้งานทุกครั้ง
- **Mobile First:** ทุก Component ต้องรองรับระบบ Master Drawer บนมือถืออย่างสมบูรณ์
- **Code Comments (Thai Localization):** ระบบหลักทั้งหมด (เช่น Classroom, Quiz, Dashboard, Profile) ได้รับการแปลคอมเมนต์ในโค้ดเป็นภาษาไทยอย่างละเอียด เพื่อให้ทีมนักพัฒนาชาวไทยสามารถทำความเข้าใจ บำรุงรักษา และขยายระบบได้ง่ายและรวดเร็วที่สุด
- **TypeScript Strictness:** ระบบมีการบังคับใช้ Type ใน TypeScript อย่างเข้มงวด และมีการแก้ไข Linting Errors (เช่นในหน้าต่างทำแบบทดสอบ `quiz/[id].vue`) อย่างสม่ำเสมอ เพื่อลดข้อผิดพลาดในขณะรันไทม์ (Runtime)
---
## 7. Getting Started & Setup (การติดตั้งและเริ่มต้นใช้งาน)
สำหรับนักพัฒนาที่จะประเมิน เวิร์กโฟลว์ หรือรัน Frontend-Learner ข้อมูลที่จำเป็นมีดังนี้:
- **Requirements:** ต้องมี Node.js (แนะนำเวอร์ชัน 18 ขึ้นไป)
- **Install Dependencies:** ติดตั้งแพ็กเกจด้วยคำสั่ง `npm install`
- **Run Development Server:** สำหรันการรันในเครื่องตัวเอง ให้ใช้ `npm run dev` ตัวระบบจะคอยรีเฟรชอัติโนมัติเมื่อโค้ดเปลี่ยน
- **Build for Production:** ใช้คำสั่ง `npm run build` และเรียกใช้ระบบผ่าน `node .output/server/index.mjs` หรือรันผ่านคำสั่ง `npm run start` (Nuxt 3)
---
## 8. Environment Variables (การตั้งค่าตัวแปรสภาพแวดล้อม)
ระบบฝั่ง Frontend จะเชื่อมต่อกับ API Backend ผ่านตัวแปรตั้งค่า ในการพัฒนาในเครื่องของตนเอง จำเป็นต้องสร้างไฟล์ `.env` ที่ root folder ของ `Frontend-Learner` โดยต้องมีข้อมูลดังต่อไปนี้:
```env
NUXT_PUBLIC_API_BASE=http://localhost:4000/api # เปลี่ยนเป็น URL หลังบ้านของ Production หากนำขึ้นโฮสต์
```
_(หมายเหตุ: Nuxt จะอ่านค่า `NUXT_PUBLIC_API_BASE` เข้าไปใน `useRuntimeConfig().public.apiBase` ให้นักพัฒนาเรียกใช้ใน Composables ตลอดทั้งแอปอัตโนมัติ)_
---
## 9. Internationalization (i18n) (ระบบสองภาษา)
ระบบมี 2 รูปแบบการแปลภาษาคือ:
- **UI Elements แบบ Static:** แปลผ่านไฟล์หรือแท็กในระบบ `@nuxtjs/i18n` (การสลับภาษาทำผ่านแฮมเบอร์เกอร์เมนูด้านบนขวา แจ้งสถานะผ่าน `useI18n()`)
- **API Content แบบ Dynamic:** ในตาราง Course หรือ Quiz จากหลังบ้าน จะใช้โครงสร้างแบบคู่ (เช่น `title: { th: "...", en: "..." }`) โดยในทุกตรรกะหน้าเรียน (Composables) จะมีฟังก์ชันช่วยอย่าง `getLocalizedText()` ไว้คอยแปลงก้อน JSON นี้เป็นภาษาที่ผู้ใช้เลือกในปัจจุบันอัตโนมัติ
---
## 10. Error Handling & UI Feedback (การจัดการหน้าตาระหว่างเข้าถึง API)
ระบบ Frontend-Learner มีแนวทางสำหรับประสบการณ์ผู้ใช้ (UX) ต่อกรณีข้อผิดพลาดที่ตายตัว ดังนี้:
- **Toast Notifications:** ในกรณีข้อมูลไม่ถูกต้อง (เช่น ล็อกอินผิด, สมัครไม่ผ่าน) ระบบจะเด้ง `Notify` ของ Quasar (`$q.notify`) แจ้งผู้ใช้จากขอบบนขวา
- **Confirmation Dialogs:** สำหรับการกระทำที่สำคัญและอาจผิดพลาดได้ (เช่น ยืนยันการส่งแบบทดสอบครั้งสุดท้ายเตือนไม่ให้กดส่งพลาด) จะใช้ `$q.dialog` แทน Toast
- **Skeleton & Loading States:** ระหว่างดึงข้อมูลระบบจากหลังบ้าน จะใช้ Skeleton (กรอบเทากะพริบ) คงรูปทรงของวิดีโอ/แบบทดสอบไว้ก่อน ป้องกันไม่ให้หน้าขาวเพื่อลดอคติโหลดช้าของนักเรียน