Compare commits

..

No commits in common. "dev" and "backend-dev-v1.0.8" have entirely different histories.

88 changed files with 2896 additions and 3706 deletions

View file

@ -169,8 +169,8 @@ export class AuditController {
throw new ValidationError('No token provided'); throw new ValidationError('No token provided');
} }
if (days < 6) { if (days < 30) {
throw new ValidationError('Cannot delete logs newer than 6 days'); throw new ValidationError('Cannot delete logs newer than 30 days');
} }
const deleted = await auditService.deleteOldLogs(days); const deleted = await auditService.deleteOldLogs(days);

View file

@ -341,7 +341,6 @@ export class CoursesStudentService {
} }
// Update last_accessed_at (fire-and-forget — ไม่ block response) // Update last_accessed_at (fire-and-forget — ไม่ block response)
if (enrollment.status === 'ENROLLED') {
prisma.enrollment.update({ prisma.enrollment.update({
where: { where: {
unique_enrollment: { unique_enrollment: {
@ -351,7 +350,6 @@ export class CoursesStudentService {
}, },
data: { last_accessed_at: new Date() }, data: { last_accessed_at: new Date() },
}).catch(err => logger.warn(`Failed to update last_accessed_at: ${err}`)); }).catch(err => logger.warn(`Failed to update last_accessed_at: ${err}`));
}
// Get all lesson progress for this user and course // Get all lesson progress for this user and course
const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));

View file

@ -79,7 +79,7 @@ export const UpdateLessonValidator = Joi.object({
'number.min': 'Duration must be at least 0' 'number.min': 'Duration must be at least 0'
}), }),
sort_order: Joi.number().integer().min(0).optional(), sort_order: Joi.number().integer().min(0).optional(),
prerequisite_lesson_ids: Joi.array().items(Joi.number().integer().positive()).allow(null).optional(), prerequisite_lesson_ids: Joi.array().items(Joi.number().integer().positive()).optional(),
is_published: Joi.boolean().optional() is_published: Joi.boolean().optional()
}); });

View file

@ -1,27 +1,20 @@
<script setup lang="ts"> <script setup>
/** // Authentication
* @file app.vue
* @description Root application component.
* Handles initialization of authentication and theme settings.
*/
// Initialize composables
const { fetchUserProfile, isAuthenticated } = useAuth() const { fetchUserProfile, isAuthenticated } = useAuth()
const { isDark, set: setTheme } = useThemeMode()
// App initialization logic // App (Mounted)
onMounted(() => { onMounted(() => {
// 1. Fetch user profile if tokens exist // 1. Login ( Token) Profile
if (isAuthenticated.value) { if (isAuthenticated.value) {
fetchUserProfile() fetchUserProfile()
} }
// 2. Initialize theme from persistent storage or system preference // 2. Theme (Dark/Light) LocalStorage
const savedTheme = localStorage.getItem('theme') const savedTheme = localStorage.getItem('theme')
if (savedTheme) { if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
setTheme(savedTheme === 'dark') document.documentElement.classList.add('dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { } else {
setTheme(true) document.documentElement.classList.remove('dark')
} }
}) })
</script> </script>

View file

@ -113,9 +113,9 @@ body {
background-attachment: fixed; background-attachment: fixed;
} }
/* a { a {
text-decoration: none; text-decoration: none;
color: #2563eb; color: #3b82f6;
transition: color 0.2s; transition: color 0.2s;
} }
@ -129,7 +129,7 @@ a:hover {
.dark a:hover { .dark a:hover {
color: #93c5fd; color: #93c5fd;
} */ }
ul { ul {
list-style: none; list-style: none;
@ -645,9 +645,9 @@ ul {
.rounded { .rounded {
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
/* .border-b { .border-b {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} */ }
.load-more-wrap { .load-more-wrap {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file AnnouncementModal.vue * @file AnnouncementModal.vue
* @description คอมโพเนนต Modal สำหรบแสดงประกาศของคอรสเรยน (Modal component to display course announcements) * @description Modal component to display course announcements
*/ */
const props = defineProps<{ const props = defineProps<{
@ -15,7 +15,7 @@ const emit = defineEmits<{
const { locale, t } = useI18n() const { locale, t } = useI18n()
// (Helper for localization) // Helper for localization
const getLocalizedText = (text: any) => { const getLocalizedText = (text: any) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text 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="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}" :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"> <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" /> <q-icon name="push_pin" color="orange" size="18px" class="transform rotate-45" />
</div> </div>

View file

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file CurriculumSidebar.vue * @file CurriculumSidebar.vue
* @description คอมโพเนนตแถบเมนานขางสำหรบแสดงหลกสตรของคอรสเรยน (บทเรยน & ตอนตางๆ) * @description Sidebar Component for displaying course curriculum (Chapters & Lessons)
* ดการการนำทางไปยงบทเรยน, แสดงสถานะการลอค, และแจงเตอนประกาศทงไมไดาน * Handles lesson navigation, locked status display, and unread announcement badge.
*/ */
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; // / Sidebar (Sidebar open state - v-model) modelValue: boolean; // Sidebar open state (v-model)
courseData: any; courseData: any;
currentLessonId?: number; currentLessonId?: number;
isLoading: boolean; isLoading: boolean;
@ -21,10 +21,10 @@ const emit = defineEmits<{
const { locale } = useI18n() const { locale } = useI18n()
// (State for expansion items) // State for expansion items
const chapterOpenState = ref<Record<string, boolean>>({}) const chapterOpenState = ref<Record<string, boolean>>({})
// (Helper for localization) // Helper for localization
const getLocalizedText = (text: any) => { const getLocalizedText = (text: any) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
@ -34,13 +34,13 @@ const getLocalizedText = (text: any) => {
return text[currentLocale] || text.th || text.en || '' return text[currentLocale] || text.th || text.en || ''
} }
// (Helper: Check if lesson is completed) // Helper: Check if lesson is completed
const isLessonCompleted = (lesson: any) => { const isLessonCompleted = (lesson: any) => {
return lesson.is_completed === true || lesson.progress?.is_completed === true return lesson.is_completed === true || lesson.progress?.is_completed === true
} }
// (Reactive Chapter Completion Status) // Reactive Chapter Completion Status
// Map chapterId -> boolean (true ) // Computes a map of chapterId -> boolean (true if all lessons are completed)
const chapterCompletionStatus = computed(() => { const chapterCompletionStatus = computed(() => {
const status: Record<string, boolean> = {} const status: Record<string, boolean> = {}
if (!props.courseData || !props.courseData.chapters) return status if (!props.courseData || !props.courseData.chapters) return status
@ -55,7 +55,7 @@ const chapterCompletionStatus = computed(() => {
return status return status
}) })
// Local (Local Progress Calculation) // Local Progress Calculation
const progressPercentage = computed(() => { const progressPercentage = computed(() => {
if (!props.courseData || !props.courseData.chapters) return 0 if (!props.courseData || !props.courseData.chapters) return 0
let total = 0 let total = 0
@ -69,7 +69,7 @@ const progressPercentage = computed(() => {
return total > 0 ? Math.round((completed / total) * 100) : 0 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) => { watch(() => props.currentLessonId, (newId) => {
if (newId && props.courseData?.chapters) { if (newId && props.courseData?.chapters) {
props.courseData.chapters.forEach((chapter: any) => { props.courseData.chapters.forEach((chapter: any) => {
@ -81,7 +81,7 @@ watch(() => props.currentLessonId, (newId) => {
} }
}, { immediate: true }) }, { immediate: true })
// (Initialize all chapters as open by default on load) // Initialize all chapters as open by default on load
watch(() => props.courseData, (newData) => { watch(() => props.courseData, (newData) => {
if (newData?.chapters) { if (newData?.chapters) {
newData.chapters.forEach((chapter: any) => { newData.chapters.forEach((chapter: any) => {
@ -102,20 +102,20 @@ watch(() => props.courseData, (newData) => {
side="right" side="right"
:width="300" :width="300"
:breakpoint="1024" :breakpoint="1024"
class="bg-slate-50 dark:!bg-slate-900 shadow-xl" 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"> <div v-if="courseData" class="flex flex-col w-full h-full overflow-hidden text-slate-900 dark:text-white relative">
<!-- 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"> <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> <h2 class="text-sm font-bold mb-4 line-clamp-2 leading-snug block w-full">{{ getLocalizedText(courseData.course.title) }}</h2>
<div class="flex justify-between items-center mb-2 w-full"> <div class="flex justify-between items-center mb-2 w-full">
<span class="text-xs font-black uppercase tracking-widest text-slate-500 dark:!text-slate-400">{{ $t('course.progress') }}</span> <span class="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
<span class="text-sm font-black text-blue-600 dark:!text-blue-400">{{ progressPercentage }}%</span> <span class="text-sm font-black text-blue-600 dark:text-blue-400">{{ progressPercentage }}%</span>
</div> </div>
<div class="h-2 w-full bg-slate-100 dark:!bg-slate-800 rounded-full overflow-hidden"> <div class="h-2 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
<div <div
class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out" class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out"
:style="{ width: `${progressPercentage}%` }" :style="{ width: `${progressPercentage}%` }"
@ -123,77 +123,77 @@ watch(() => props.courseData, (newData) => {
</div> </div>
</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"> <div class="flex-1 overflow-y-auto bg-slate-50 dark:bg-[#0f1219] w-full p-4 space-y-3">
<q-list class="block w-full"> <q-list class="block w-full">
<div v-for="(chapter, idx) in courseData.chapters" :key="chapter.id" class="block w-full mb-3"> <div v-for="(chapter, idx) in courseData.chapters" :key="chapter.id" class="block w-full mb-3">
<!-- กลองขอมลของบท (Chapter Accordion) --> <!-- Chapter Accordion -->
<q-expansion-item <q-expansion-item
v-model="chapterOpenState[chapter.id]" 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" class="bg-white dark:bg-[#1a1e29] rounded-xl overflow-hidden shadow-sm border border-slate-200 dark:border-slate-800 w-full"
header-class="rounded-t-xl w-full text-slate-900 dark:!text-white" header-class="rounded-t-xl w-full"
expand-icon-class="text-slate-400 dark:!text-slate-300" expand-icon-class="text-slate-400"
> >
<template v-slot:header> <template v-slot:header>
<div class="flex items-center w-full py-3 text-slate-900 dark:!text-white"> <div class="flex items-center w-full py-3 text-slate-900 dark:text-white">
<div class="mr-3 flex-shrink-0"> <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" <div class="w-7 h-7 rounded-full border-2 flex items-center justify-center transition-colors font-bold"
:class="chapterCompletionStatus[chapter.id] :class="chapterCompletionStatus[chapter.id]
? 'border-green-500 text-green-500 bg-green-50 dark:!bg-green-500/10' ? 'border-green-500 text-green-500 bg-green-50 dark:bg-green-500/10'
: 'border-slate-300 dark:!border-slate-600 text-slate-500 dark:!text-slate-400 bg-slate-100 dark:!bg-slate-700'"> : 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-800'">
<q-icon v-if="chapterCompletionStatus[chapter.id]" name="check" size="14px" class="font-bold" /> <q-icon v-if="chapterCompletionStatus[chapter.id]" name="check" size="14px" class="font-bold" />
<span v-else class="text-[10px]">{{ Number(idx) + 1 }}</span> <span v-else class="text-[10px]">{{ Number(idx) + 1 }}</span>
</div> </div>
</div> </div>
<!-- ดการตวอกษรทนเกนอยางชดเจน (Explicitly handle text overflow) --> <!-- Explicitly handle text overflow -->
<div class="flex-1 min-w-0 pr-2 overflow-hidden"> <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="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"> <div class="text-[10px] text-slate-500 dark:text-slate-400 font-normal truncate block w-full">
{{ chapter.lessons.length }} {{ $t('course.lessonsUnit') }} {{ chapter.lessons.length }} {{ $t('course.lessonsUnit') }}
</div> </div>
</div> </div>
</div> </div>
</template> </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 class="bg-slate-50 dark:bg-[#0f1219]/50 border-t border-slate-100 dark:border-slate-800 w-full">
<div <div
v-for="(lesson, lIdx) in chapter.lessons" v-for="(lesson, lIdx) in chapter.lessons"
:key="lesson.id" :key="lesson.id"
class="flex items-center px-4 py-3 cursor-pointer transition-all border-l-4 hover:bg-slate-100 dark:hover:!bg-slate-700/50 w-full" class="flex items-center px-4 py-3 cursor-pointer transition-all border-l-4 hover:bg-slate-100 dark:hover:bg-slate-800/50 w-full"
:class="currentLessonId === lesson.id :class="currentLessonId === lesson.id
? 'border-blue-600 bg-blue-50 dark:!bg-blue-900/40' ? 'border-blue-600 bg-blue-50 dark:bg-blue-900/10'
: 'border-transparent'" : 'border-transparent'"
@click="!lesson.is_locked && emit('select-lesson', lesson.id)" @click="!lesson.is_locked && emit('select-lesson', lesson.id)"
> >
<!-- ไอคอนสถานะของบทเรยน (Lesson Status Icon) --> <!-- Lesson Status Icon -->
<div class="mr-3 flex-shrink-0"> <div class="mr-3 flex-shrink-0">
<!-- เรยนจบแล (สำคญท) (Completed - Takes Precedence) --> <!-- Completed (Takes Precedence) -->
<q-icon v-if="isLessonCompleted(lesson)" <q-icon v-if="isLessonCompleted(lesson)"
name="check_circle" name="check_circle"
class="text-green-500" class="text-green-500"
size="20px" size="20px"
/> />
<!-- กำลงเรยนอย (Active/Playing - If not completed) --> <!-- Active/Playing (If not completed) -->
<q-icon v-else-if="currentLessonId === lesson.id" <q-icon v-else-if="currentLessonId === lesson.id"
name="play_circle_filled" name="play_circle_filled"
class="text-blue-600 dark:!text-blue-400 animate-pulse" class="text-blue-600 dark:text-blue-400 animate-pulse"
size="20px" size="20px"
/> />
<!-- กลอคอย (Locked) --> <!-- Locked -->
<q-icon v-else-if="lesson.is_locked" <q-icon v-else-if="lesson.is_locked"
name="lock" name="lock"
class="text-slate-400 dark:!text-slate-500 opacity-70" class="text-slate-400 opacity-70"
size="18px" 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 v-else class="w-[18px] h-[18px] rounded-full border-2 border-slate-300 dark:border-slate-600"></div>
</div> </div>
<div class="flex-1 min-w-0 overflow-hidden"> <div class="flex-1 min-w-0 overflow-hidden">
<div class="text-xs font-bold truncate leading-snug block w-full" <div class="text-xs font-bold truncate leading-snug block w-full"
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:!text-blue-300' : 'text-slate-600 dark:!text-slate-300'" :class="currentLessonId === lesson.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-600 dark:text-slate-300'"
> >
{{ getLocalizedText(lesson.title) }} {{ getLocalizedText(lesson.title) }}
</div> </div>
@ -214,7 +214,7 @@ watch(() => props.courseData, (newData) => {
</template> </template>
<style scoped> <style scoped>
/* สครอลบาร์ปรับแต่งเพื่อความสวยงาม (Custom scrollbar for better aesthetics) */ /* Custom scrollbar for better aesthetics */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 4px; width: 4px;
} }

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file VideoPlayer.vue * @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<{ const props = defineProps<{
@ -22,7 +22,7 @@ const videoProgress = ref(0);
const currentTime = ref(0); const currentTime = ref(0);
const duration = ref(0); const duration = ref(0);
// (Media Prefs) // Media Prefs
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs(); const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs();
const volumeIcon = computed(() => { const volumeIcon = computed(() => {
@ -40,7 +40,7 @@ const formatTime = (time: number) => {
const currentTimeDisplay = computed(() => formatTime(currentTime.value)); const currentTimeDisplay = computed(() => formatTime(currentTime.value));
const durationDisplay = computed(() => formatTime(duration.value || 0)); const durationDisplay = computed(() => formatTime(duration.value || 0));
// YouTube (YouTube Helper Logic) // YouTube Helper Logic
const isYoutube = computed(() => { const isYoutube = computed(() => {
const s = props.src.toLowerCase(); const s = props.src.toLowerCase();
return s.includes('youtube.com') || s.includes('youtu.be'); return s.includes('youtube.com') || s.includes('youtu.be');
@ -50,7 +50,7 @@ const youtubeEmbedUrl = computed(() => {
if (!isYoutube.value) return ''; if (!isYoutube.value) return '';
let videoId = ''; let videoId = '';
// (Extract Video ID) // Extract Video ID
if (props.src.includes('youtu.be')) { if (props.src.includes('youtu.be')) {
videoId = props.src.split('youtu.be/')[1]?.split('?')[0]; videoId = props.src.split('youtu.be/')[1]?.split('?')[0];
} else { } else {
@ -58,18 +58,18 @@ const youtubeEmbedUrl = computed(() => {
videoId = urlParams.get('v') || ''; videoId = urlParams.get('v') || '';
} }
// URL jsapi (Return Embed URL with enablejsapi=1) // Return Embed URL with enablejsapi=1
return `https://www.youtube.com/embed/${videoId}?enablejsapi=1&rel=0`; return `https://www.youtube.com/embed/${videoId}?enablejsapi=1&rel=0`;
}); });
// YouTube API (YouTube API Tracking) // YouTube API Tracking
let ytPlayer: any = null; let ytPlayer: any = null;
let ytInterval: any = null; let ytInterval: any = null;
const initYoutubeAPI = () => { const initYoutubeAPI = () => {
if (!isYoutube.value || typeof window === 'undefined') return; if (!isYoutube.value || typeof window === 'undefined') return;
// API (Load API Script if not exists) // Load API Script if not exists
if (!(window as any).YT) { if (!(window as any).YT) {
const tag = document.createElement('script'); const tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api"; tag.src = "https://www.youtube.com/iframe_api";
@ -83,7 +83,7 @@ const initYoutubeAPI = () => {
'onReady': (event: any) => { 'onReady': (event: any) => {
duration.value = event.target.getDuration(); duration.value = event.target.getDuration();
// YouTube (Resume Logic for YouTube) // Resume Logic for YouTube
if (props.initialSeekTime && props.initialSeekTime > 0) { if (props.initialSeekTime && props.initialSeekTime > 0) {
event.target.seekTo(props.initialSeekTime, true); event.target.seekTo(props.initialSeekTime, true);
} }
@ -118,7 +118,7 @@ const startYTTracking = () => {
currentTime.value = ytPlayer.getCurrentTime(); currentTime.value = ytPlayer.getCurrentTime();
emit('timeupdate', currentTime.value, duration.value); emit('timeupdate', currentTime.value, duration.value);
} }
}, 1000); // (Check every second) }, 1000); // Check every second
}; };
const stopYTTracking = () => { const stopYTTracking = () => {
@ -145,7 +145,7 @@ onUnmounted(() => {
destroyYoutubePlayer(); destroyYoutubePlayer();
}); });
// src (Watch for src change to re-init) // Watch for src change to re-init
watch(() => props.src, (newSrc, oldSrc) => { watch(() => props.src, (newSrc, oldSrc) => {
if (newSrc !== oldSrc) { if (newSrc !== oldSrc) {
destroyYoutubePlayer(); destroyYoutubePlayer();
@ -174,8 +174,8 @@ const togglePlay = () => {
playPromise.then(() => { playPromise.then(() => {
isPlaying.value = true; isPlaying.value = true;
}).catch(error => { }).catch(error => {
// (Auto-play was prevented or play was interrupted) // Auto-play was prevented or play was interrupted
// (We can safely ignore this error) // We can safely ignore this error
console.log("Video play request handled:", error.name); console.log("Video play request handled:", error.name);
}); });
} }
@ -223,14 +223,14 @@ const handleVolumeChange = (val: any) => {
setVolume(newVol); setVolume(newVol);
}; };
// video ref (Expose video ref for parent to control if needed) // Expose video ref for parent to control if needed
defineExpose({ defineExpose({
videoRef, videoRef,
pause: () => videoRef.value?.pause(), pause: () => videoRef.value?.pause(),
currentTime: () => videoRef.value?.currentTime || 0 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], () => { watch([volume, isMuted], () => {
if (videoRef.value) applyTo(videoRef.value); if (videoRef.value) applyTo(videoRef.value);
}); });
@ -238,7 +238,7 @@ watch([volume, isMuted], () => {
<template> <template>
<div class="bg-black rounded-xl overflow-hidden shadow-2xl mb-6 aspect-video relative group ring-1 ring-white/10"> <div class="bg-black rounded-xl overflow-hidden shadow-2xl mb-6 aspect-video relative group ring-1 ring-white/10">
<!-- 1. เครองเล YouTube (YouTube Player) --> <!-- 1. YouTube Player -->
<iframe <iframe
v-if="isYoutube" v-if="isYoutube"
id="youtube-iframe" id="youtube-iframe"
@ -249,7 +249,7 @@ watch([volume, isMuted], () => {
allowfullscreen allowfullscreen
></iframe> ></iframe>
<!-- 2. เครองเลนวโอ HTML5 มาตรฐาน (Standard HTML5 Video Player) --> <!-- 2. Standard HTML5 Video Player -->
<div v-else class="w-full h-full relative group/video cursor-pointer"> <div v-else class="w-full h-full relative group/video cursor-pointer">
<video <video
ref="videoRef" ref="videoRef"
@ -262,9 +262,9 @@ watch([volume, isMuted], () => {
@ended="handleEnded" @ended="handleEnded"
/> />
<!-- เลเยอรควบคมแบบกำหนดเอง (Overlay) เฉพาะสำหรบวโอ HTML5 เทาน (Custom Controls Overlay (Only for HTML5 Video)) --> <!-- 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"> <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="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 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> </div>
@ -275,7 +275,7 @@ watch([volume, isMuted], () => {
<div class="flex-grow"></div> <div class="flex-grow"></div>
<!-- วควบคมระดบเสยง (Volume Control) --> <!-- Volume Control -->
<div class="flex items-center gap-2 group/volume relative"> <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" /> <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"> <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"> <script setup lang="ts">
/** /**
* @file FormInput.vue * @file FormInput.vue
* @description คอมโพเนนตองกรอกขอม (Input) แบบนำกลบมาใชใหมได พรอมรองรบขอความปายกำก, ดการขอผดพลาด และสถานะปดใชงาน/งคบกรอก * @description Reusable input component with label, error handling, and support for disabled/required states.
* รองรบการสลบซอน/แสดงรหสผาน * Now supports password visibility toggle.
*/ */
const props = defineProps<{ const props = defineProps<{
@ -16,19 +16,19 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
/** อัปเดตค่า v-model (Update v-model value) */ /** Update v-model value */
'update:modelValue': [value: string] 'update:modelValue': [value: string]
}>() }>()
// / (Password visibility state) // Password visibility state
const showPassword = ref(false) const showPassword = ref(false)
// (Toggle function) // Toggle function
const togglePassword = () => { const togglePassword = () => {
showPassword.value = !showPassword.value showPassword.value = !showPassword.value
} }
// بناءً pada state (Compute input type based on visibility state) // Compute input type based on visibility state
const inputType = computed(() => { const inputType = computed(() => {
if (props.type === 'password') { if (props.type === 'password') {
return showPassword.value ? 'text' : 'password' return showPassword.value ? 'text' : 'password'
@ -59,7 +59,7 @@ const updateValue = (event: Event) => {
@input="updateValue" @input="updateValue"
> >
<!-- มสลบซอน/แสดงรหสผาน (Password Toggle Button) --> <!-- Password Toggle Button -->
<button <button
v-if="type === 'password'" v-if="type === 'password'"
type="button" type="button"
@ -67,13 +67,13 @@ const updateValue = (event: Event) => {
@click="togglePassword" @click="togglePassword"
tabindex="-1" 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"> <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"/> <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"/> <circle cx="12" cy="12" r="3"/>
</svg> </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"> <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="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"/> <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"> <script setup lang="ts">
/** /**
* @file GlobalLoader.vue * @file GlobalLoader.vue
* @description คอมโพเนนตหนาจอโหลดแบบเตมจอ (Global full-screen loading) แสดงผลตอนเปลยนหน * @description Global full-screen loading overlay that triggers during page navigation.
* พรมแอนเมชนโลโกขยบไดแบบพรเมยม * Uses a premium pulsing logo animation.
*/ */
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const isLoading = ref(false) const isLoading = ref(false)
// Nuxt hook (Hook into Nuxt page transitions) // Hook into Nuxt page transitions
nuxtApp.hook('page:start', () => { nuxtApp.hook('page:start', () => {
isLoading.value = true isLoading.value = true
}) })
nuxtApp.hook('page:finish', () => { nuxtApp.hook('page:finish', () => {
// (Add a small delay for better UX) // Add a small delay for better UX (prevents flickering on fast loads)
setTimeout(() => { setTimeout(() => {
isLoading.value = false isLoading.value = false
}, 500) }, 500)
@ -25,14 +25,14 @@ nuxtApp.hook('page:finish', () => {
<Transition name="fade"> <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 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"> <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-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"> <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> <span class="text-2xl font-black text-white">E</span>
</div> </div>
</div> </div>
<!-- อความระหวางโหลด (Loading Text) --> <!-- Loading Text -->
<div class="flex flex-col items-center gap-2"> <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> <h3 class="text-lg font-bold text-slate-800 dark:text-white tracking-wide">e-Learning</h3>
<div class="flex gap-1"> <div class="flex gap-1">

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file CourseCard.vue * @file CourseCard.vue
* @description คอมโพเนนตการดแสดงคอรสเรยนมาตรฐาน (Standardized Course Card Component) * @description Standardized Course Card Component.
* ใชงาน: <CourseCard :id="1" title="..." ... /> * Usage: <CourseCard :id="1" title="..." ... />
*/ */
interface CourseCardProps { interface CourseCardProps {
@ -20,7 +20,7 @@ interface CourseCardProps {
image?: string image?: string
loading?: boolean loading?: boolean
// (Action Flags) // Action Flags
showViewDetails?: boolean showViewDetails?: boolean
showContinue?: boolean showContinue?: boolean
showCertificate?: boolean showCertificate?: boolean
@ -57,9 +57,9 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</script> </script>
<template> <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"> <div class="group relative flex flex-col bg-white dark:!bg-[#0f172a] rounded-3xl overflow-hidden border border-slate-200 dark:border-slate-800 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"> <div class="relative w-full aspect-video overflow-hidden">
<img <img
v-if="image" 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" /> <q-icon name="image" size="48px" class="text-slate-300 dark:text-slate-600" />
</div> </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> <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"> <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"> <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" /> <q-icon name="check" size="24px" />
@ -84,9 +84,9 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div> </div>
</div> </div>
<!-- วนเนอหาขอม (Content Section) --> <!-- Content Section -->
<div class="p-6 flex flex-col flex-grow"> <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"> <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"> <span v-if="lessons" class="flex items-center gap-1">
<q-icon name="menu_book" size="14px" /> {{ lessons }} {{ $t('course.lessonsUnit') }} <q-icon name="menu_book" size="14px" /> {{ lessons }} {{ $t('course.lessonsUnit') }}
@ -96,18 +96,18 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</span> </span>
</div> </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"> <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 }} {{ displayTitle }}
</h3> </h3>
<!-- รายละเอยดเพมเต (Description) --> <!-- Description -->
<p v-if="displayDescription" class="text-sm text-slate-500 dark:text-slate-400 line-clamp-2 mb-6"> <p v-if="displayDescription" class="text-sm text-slate-500 dark:text-slate-400 line-clamp-2 mb-6">
{{ displayDescription }} {{ displayDescription }}
</p> </p>
<div class="mt-auto pt-4"> <div class="mt-auto pt-4">
<!-- หลอดความคบหน (Progress Bar) --> <!-- Progress Bar -->
<div v-if="progress !== undefined && !completed && !hideProgress" class="mb-4"> <div v-if="progress !== undefined && !completed && !hideProgress" class="mb-4">
<div class="flex justify-between text-[10px] font-bold uppercase mb-1"> <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> <span class="text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
@ -118,19 +118,19 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div> </div>
</div> </div>
<!-- มปฏการตางๆ (Action Buttons) --> <!-- Action Buttons -->
<div v-if="!hideActions" class="flex flex-col gap-3"> <div v-if="!hideActions" class="flex flex-col gap-3">
<!-- มดรายละเอยด (มรอง) (View Details - Secondary Action) --> <!-- View Details (Secondary Action) -->
<q-btn <q-btn
v-if="showViewDetails && !completed && !progress" v-if="showViewDetails && !completed && !progress"
flat flat
rounded rounded
class="w-full font-bold !text-blue-600 !bg-blue-50 hover:!bg-blue-100 dark:!bg-blue-500/10 dark:!text-blue-400 dark:hover:!bg-blue-500/20" class="w-full font-bold !text-blue-600 !bg-blue-50 hover:!bg-blue-100 dark:!bg-blue-900/40 dark:!text-blue-300 dark:hover:!bg-blue-900/60"
:label="$t('menu.viewDetails')" :label="$t('menu.viewDetails')"
:to="`/course/${id}`" :to="`/course/${id}`"
/> />
<!-- มเรยนต/เรมเรยน (มหล) (Continue Learning - Primary Action) --> <!-- Continue Learning (Primary Action) -->
<q-btn <q-btn
v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)" v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)"
unelevated unelevated
@ -142,7 +142,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div> </div>
<div v-if="completed" class="space-y-2"> <div v-if="completed" class="space-y-2">
<!-- มเรยนอกคร (Study Again) --> <!-- Study Again -->
<q-btn <q-btn
v-if="showStudyAgain" v-if="showStudyAgain"
unelevated unelevated
@ -152,7 +152,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
:to="`/classroom/learning?course_id=${id}`" :to="`/classroom/learning?course_id=${id}`"
/> />
<!-- มดาวนโหลดใบรบรอง (Download Certificate) --> <!-- Download Certificate -->
<q-btn <q-btn
v-if="showCertificate" v-if="showCertificate"
unelevated unelevated
@ -168,5 +168,5 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</template> </template>
<style scoped> <style scoped>
/* ใส่โค้ด CSS เพิ่มได้ถ้าต้องการครอบคลุมเฉพาะไฟล์นี้ (Scoped overrides if needed) */ /* Scoped overrides if needed */
</style> </style>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file CategorySidebar.vue * @file CategorySidebar.vue
* @description แถบเมนานขางสำหรบกรองคอรสตามหมวดหม (Sidebar for filtering courses by category) * @description Sidebar for filtering courses by category
*/ */
const props = defineProps<{ const props = defineProps<{
@ -81,13 +81,13 @@ const toggleCategory = (id: number) => {
{{ getLocalizedText(cat.name) }} {{ getLocalizedText(cat.name) }}
</span> </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 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> </div>
</div> </div>
<!-- มแสดงเพมเต/แสดงนอยลง (Show More/Less Action) --> <!-- Show More/Less Action -->
<div <div
v-if="categories.length > 5" v-if="categories.length > 5"
@click="showAllCategories = !showAllCategories" @click="showAllCategories = !showAllCategories"

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file CourseDetailView.vue * @file CourseDetailView.vue
* @description แสดงรายละเอยดคอรสแบบรวดเร รวมถงตวอยางวโอ, หลกสตร, และระบบการลงทะเบยน * @description Quick view of course details including video preview, curriculum, and enroll logic
*/ */
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
@ -51,9 +51,9 @@ const handleEnroll = () => {
if(!props.course) return; if(!props.course) return;
enrollmentLoading.value = true; enrollmentLoading.value = true;
emit('enroll', props.course.id); emit('enroll', props.course.id);
// Loading event prop // Loading state reset depends on parent, but locally we can reset after emit or keep until prop changes
// event (just emit) // In this pattern, we just emit.
setTimeout(() => enrollmentLoading.value = false, 2000); // (Safety timeout) setTimeout(() => enrollmentLoading.value = false, 2000); // Safety timeout
}; };
const instructorData = computed(() => { const instructorData = computed(() => {
if (props.course?.instructors && props.course.instructors.length > 0) { if (props.course?.instructors && props.course.instructors.length > 0) {
@ -66,11 +66,16 @@ const instructorData = computed(() => {
<template> <template>
<div class="animate-fade-in-up"> <div class="animate-fade-in-up">
<div class="flex items-center gap-2 mb-8 group cursor-pointer" @click="emit('back')">
<q-icon name="arrow_back" size="20px" class="text-slate-400 group-hover:text-blue-600 transition-colors" />
<span class="text-sm font-bold text-slate-500 group-hover:text-blue-600 transition-colors uppercase tracking-widest">{{ $t('common.back') }}</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <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"> <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]"> <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"> <template v-if="course.media?.video_url">
<video <video
@ -81,19 +86,19 @@ const instructorData = computed(() => {
<source :src="course.media.video_url" type="video/mp4"> <source :src="course.media.video_url" type="video/mp4">
{{ $t('course.videoNotSupported') }} {{ $t('course.videoNotSupported') }}
</video> </video>
<!-- มเลนวโอแบบปรบแตงเองตอนยงไมเล (Custom Play Overlay when not playing) --> <!-- Custom Play Overlay when not playing - simple version is often best -->
</template> </template>
<!-- แสดงรปภาพสวยๆ กรณไมโอ (Beautiful Image Showcase if no video) --> <!-- Beautiful Image Showcase if no video -->
<template v-else> <template v-else>
<div class="w-full h-full flex items-center justify-center relative overflow-hidden bg-slate-950 group"> <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 <img
v-if="course.thumbnail_url || course.cover_image" v-if="course.thumbnail_url || course.cover_image"
:src="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" class="absolute inset-0 w-full h-full object-cover opacity-40 blur-2xl scale-125"
/> />
<!-- ปหลกแบบคมช (Main Sharp Image) --> <!-- Main Sharp Image -->
<img <img
v-if="course.thumbnail_url || course.cover_image" v-if="course.thumbnail_url || course.cover_image"
:src="course.thumbnail_url || course.cover_image" :src="course.thumbnail_url || course.cover_image"
@ -107,7 +112,7 @@ const instructorData = computed(() => {
</template> </template>
</div> </div>
<!-- อคอรสและรายละเอยด (Course Title & Description) --> <!-- Course Title & Description -->
<div> <div>
<h1 class="text-3xl md:text-4xl font-extrabold text-slate-900 dark:text-white mb-4 leading-tight"> <h1 class="text-3xl md:text-4xl font-extrabold text-slate-900 dark:text-white mb-4 leading-tight">
{{ getLocalizedText(course.title) }} {{ getLocalizedText(course.title) }}
@ -118,10 +123,10 @@ const instructorData = computed(() => {
</div> </div>
</div> </div>
<!-- รายละเอยดคอร - แแบบหนาเดยว (Course Detail - Single Page Layout) --> <!-- Course Detail - Single Page Layout -->
<div class="space-y-10"> <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"> <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"> <q-avatar size="64px">
<img :src="instructorData?.profile?.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" /> <img :src="instructorData?.profile?.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" />
@ -135,7 +140,7 @@ const instructorData = computed(() => {
</div> </div>
</div> </div>
<!-- รายละเอยดหลกสตร / บทเรยน (Curriculum / Lesson Details) --> <!-- Curriculum / Lesson Details -->
<div> <div>
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-slate-900 dark:text-white"> <h3 class="text-xl font-bold text-slate-900 dark:text-white">
@ -148,7 +153,7 @@ const instructorData = computed(() => {
<div class="space-y-4"> <div class="space-y-4">
<div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="group"> <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"> <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="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> <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 +162,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> <span class="text-[10px] uppercase font-black tracking-widest text-slate-400 opacity-60">{{ chapter.lessons?.length || 0 }} {{ $t('course.lessonsUnit') }}</span>
</div> </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 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 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'"> <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 +178,7 @@ const instructorData = computed(() => {
</div> </div>
</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"> <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" /> <q-icon name="menu_book" size="40px" class="mb-2 opacity-50" />
<p class="text-sm font-medium">{{ $t('course.noContent') }}</p> <p class="text-sm font-medium">{{ $t('course.noContent') }}</p>
@ -185,11 +190,11 @@ const instructorData = computed(() => {
</div> </div>
<!-- านขวา: การดลงทะเบยน (Right: Enrollment Card) --> <!-- Right: Enrollment Card -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<div class="sticky top-24"> <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"> <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="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"> <div class="relative">

View file

@ -1,116 +1,183 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file AppHeader.vue * @file AppHeader.vue
* @description แถบเมนานบนหล (Header) สำหรบหนาแดชบอร (Dashboard) ของระบบ EduLearn * @description The main header for the authenticated application dashboard.
* Uses Quasar QToolbar.
*/ */
import { ref, computed } from "vue";
const props = defineProps<{ const props = defineProps<{
/** ควบคุมการแสดงผลของปุ่มเปิด/ปิดแถบเมนูด้านข้าง (Sidebar) */ /** Controls visibility of the search bar */
showSearch?: boolean;
/** Controls visibility of the sidebar toggle button */
showSidebarToggle?: boolean; showSidebarToggle?: boolean;
/** Type of navigation links to display */
navType?: "public" | "learner";
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
/** ส่งสัญญาณ (Emit) เมื่อผู้ใช้คลิกที่ปุ่มแฮมเบอร์เกอร์เมนู */ /** Emitted when the hamburger menu is clicked */
toggleSidebar: []; toggleSidebar: [];
}>(); }>();
const { currentUser } = useAuth(); const { t } = useI18n();
const { locale, setLocale } = useI18n(); const route = useRoute();
const { isDark, set: setTheme } = useThemeMode();
const toggleLanguage = () => { // Automatically determine navType based on route if not explicitly passed
setLocale(locale.value === 'th' ? 'en' : 'th'); const navTypeComputed = computed(() => {
}; if (props.navType) return props.navType;
// Show learner nav for dashboard, browse, classroom, and course details
const learnerRoutes = ["/dashboard", "/browse", "/classroom", "/course"];
return learnerRoutes.some((r) => route.path.startsWith(r))
? "learner"
: "public";
});
const toggleTheme = () => { const searchText = ref("");
setTheme(!isDark.value);
};
</script> </script>
<template> <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"> <q-toolbar class="bg-white text-slate-900 h-16 shadow-sm border-none p-0">
<div
<!-- านซาย: มยอขยายแถบเมนานขาง (Hamburger Toggle) --> class="container mx-auto w-full px-6 md:px-12 flex items-center h-full"
>
<!-- Mobile Menu Toggle -->
<q-btn <q-btn
v-if="showSidebarToggle !== false && navTypeComputed !== 'learner'"
flat flat
round round
dense dense
icon="menu" icon="menu"
class="text-slate-400 hover:text-blue-600 transition-colors" class="lg:hidden mr-2 text-gray-500"
size="16px"
@click="$emit('toggleSidebar')" @click="$emit('toggleSidebar')"
/> />
<!-- Branding -->
<div
class="flex items-center gap-3 cursor-pointer group"
@click="navigateTo('/dashboard')"
>
<div
class="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white font-black shadow-lg shadow-blue-600/30 group-hover:scale-110 transition-transform"
>
E
</div>
<div class="flex flex-col">
<span
class="font-black text-lg leading-none tracking-tight text-slate-900 group-hover:text-blue-600 transition-colors"
>E-Learning</span
>
<span
class="text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500"
>Platform</span
>
</div>
</div>
<div
class="flex items-center min-w-[200px] md:min-w-[320px] max-w-sm ml-8 mr-8 h-10"
>
<q-input
v-model="searchText"
dense
borderless
:placeholder="$t('menu.searchCourses')"
class="search-input w-full bg-slate-100/60 px-4 rounded-full transition-all duration-300 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-100 h-full flex items-center"
@keyup.enter="navigateTo(`/browse/discovery?search=${searchText}`)"
>
<template #prepend>
<q-icon name="search" size="xs" class="text-blue-600" />
</template>
</q-input>
</div>
<!-- Desktop Navigation -->
<nav class="flex items-center gap-8 text-[14px] font-bold text-slate-600">
<!-- Learner Navigation (Dashboard Mode) -->
<template v-if="navTypeComputed === 'learner'">
<NuxtLink
to="/dashboard"
class="hover:text-blue-600 transition-colors uppercase tracking-wider"
active-class="text-blue-600"
>
{{ $t("sidebar.overview") }}
</NuxtLink>
<NuxtLink
to="/browse/discovery"
class="hover:text-blue-600 transition-colors uppercase tracking-wider"
active-class="text-blue-600"
>
{{ $t("landing.allCourses") }}
</NuxtLink>
<NuxtLink
to="/dashboard/my-courses"
class="hover:text-blue-600 transition-colors uppercase tracking-wider"
active-class="text-blue-600"
>
{{ $t("sidebar.myCourses") || "คอร์สเรียนของฉัน" }}
</NuxtLink>
</template>
<!-- Public Navigation (Default) -->
<template v-else>
<div
class="cursor-pointer hover:text-purple-600 flex items-center gap-1 transition-colors"
>
คอรสเรยนทงหมด <q-icon name="keyboard_arrow_down" />
<q-menu>
<q-list dense style="min-width: 150px">
<q-item clickable v-close-popup to="/browse">
<q-item-section>งหมด</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
<div
class="cursor-pointer hover:text-purple-600 flex items-center gap-1 transition-colors"
>
หลกสตร Onsite <q-icon name="keyboard_arrow_down" />
</div>
<NuxtLink
to="/browse/recommended"
class="hover:text-purple-600 transition-colors"
>
คอรสแนะนำ
</NuxtLink>
<div class="cursor-pointer hover:text-purple-600 transition-colors">
บทความ
</div>
<div class="cursor-pointer hover:text-purple-600 transition-colors">
สมาชกรายป
</div>
<div class="cursor-pointer hover:text-purple-600 transition-colors">
สำหรบองคกร
</div>
</template>
</nav>
<q-space /> <q-space />
<!-- วนการตงคาทางดานขวา (Right Section) --> <!-- Right Actions -->
<div class="flex items-center gap-2 sm:gap-4 md:gap-6 no-wrap"> <div class="flex items-center gap-2 sm:gap-4 text-gray-500">
<!-- Search Icon -->
<!-- มสลบธ (Theme Toggle) --> <!-- Language -->
<q-btn <LanguageSwitcher />
flat
round
dense
:icon="isDark ? 'dark_mode' : 'light_mode'"
:class="isDark ? 'text-blue-400' : 'text-amber-500'"
class="transition-all active:scale-90"
size="12px"
@click="toggleTheme"
>
<q-tooltip>{{ isDark ? 'โหมดกลางคืน' : 'โหมดกลางวัน' }}</q-tooltip>
</q-btn>
<!-- วสลบภาษาแบบแคปซ (Language Switcher) --> <!-- User Profile -->
<div <UserMenu />
@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"
>
<div :class="locale === 'th' ? '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">TH</div>
<div class="w-[1px] h-3 bg-slate-200 dark:bg-slate-700 mx-0.5"></div>
<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> </div>
<!-- เสนค (Divider) -->
<div class="hidden sm:block w-[1px] h-8 bg-slate-100 dark:bg-slate-800"></div>
<!-- วนขอมลผใชงาน (User Profile) -->
<div class="flex items-center gap-3 cursor-pointer group" @click="navigateTo('/dashboard/profile')">
<!-- อและบทบาท (แสดงเฉพาะบนจอทใหญกว 600px) -->
<div class="user-info-text flex flex-col items-end text-right">
<span class="text-[15px] font-bold text-slate-900 dark:text-white leading-tight">
{{ currentUser?.firstName || 'User' }} {{ currentUser?.lastName || '' }}
</span>
<span class="text-[11px] text-slate-500 font-medium">{{ $t('common.student') }}</span>
</div>
<!-- ปโปรไฟลพรอมวงแหวน Gradient มนวล -->
<div class="relative p-[3px] rounded-full bg-gradient-to-tr from-[#FFD1D1] via-[#E2E8FF] to-[#D1F7FF] dark:from-slate-800 dark:to-slate-700 transition-transform group-hover:scale-105">
<div class="bg-white dark:bg-[#020617] p-[1.5px] rounded-full shadow-sm">
<UserAvatar
:photoURL="currentUser?.photoURL"
:firstName="currentUser?.firstName"
:lastName="currentUser?.lastName"
size="40"
class="w-[40px] h-[40px]"
/>
</div>
</div>
</div>
</div> </div>
</q-toolbar> </q-toolbar>
</template> </template>
<style scoped> <style scoped>
/* บังคับให้ความสูงของ Header เท่ากันเสมอ (Ensure toolbar height is consistent) */ .search-input :deep(.q-field__control) {
:deep(.q-toolbar) { border-radius: 9999px; /* Full rounded */
min-height: 80px;
}
/* ซ่อนชื่อผู้ใช้ไว้เฉพาะบนหน้าจอมือถือขนาดเล็กเท่านั้น (Hide user name only on small mobile screens) */
@media (max-width: 600px) {
.user-info-text {
display: none !important;
} }
.search-input :deep(.q-field__control:before) {
border-color: #e2e8f0; /* slate-200 */
} }
</style> </style>

View file

@ -1,145 +1,79 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file AppSidebar.vue * @file AppSidebar.vue
* @description เมนานขางสำหรบการนำทาง (Sidebar Navigation) * @description Sidebar navigation for the authenticated dashboard.
* Uses Quasar QList for structure.
*/ */
// 1. Composables const { sidebarItems } = useNavItems()
import { useQuasar } from 'quasar'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const navItems = sidebarItems
const $q = useQuasar()
const { logout } = useAuth()
const { isDark } = useThemeMode()
// 2. (Main Navigation)
const menuItems = computed(() => [
{ to: '/dashboard', icon: 'grid_view', label: t('sidebar.overview') },
{ to: '/dashboard/my-courses', icon: 'book', label: t('sidebar.myCourses') }
])
// 3. (Account Navigation) const handleNavigate = (path: string) => {
const accountItems = computed(() => [ if (import.meta.client) {
{ to: '/dashboard/profile', icon: 'settings', label: t('userMenu.settings') } window.location.href = path
]) }
// 4. (Logout Function)
const handleLogout = () => {
$q.dialog({
title: t('auth.logoutConfirmTitle'),
message: t('auth.logoutConfirmMessage'),
cancel: {
flat: true,
color: isDark.value ? 'grey-4' : 'grey-7',
label: t('common.cancel')
},
ok: {
flat: false,
color: 'red-500',
label: t('auth.logout'),
unelevated: true
},
dark: isDark.value,
class: 'p-4 rounded-2xl text-slate-900 dark:text-white',
persistent: true
}).onOk(async () => {
await logout()
})
} }
</script> </script>
<template> <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"> <div class="flex flex-col h-full bg-transparent border-r border-slate-200 dark:border-slate-800">
<!-- โลโกแบรนด (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" />
</div>
<span class="text-[22px] font-black tracking-tight text-slate-800 dark:text-white">EduLearn</span>
</div>
<!-- การนำทางหล (Main Navigation) --> <!-- Navigation Items -->
<div class="space-y-1 mb-8"> <q-list padding class="text-slate-600 dark:text-slate-400 flex-grow px-3 pt-6">
<NuxtLink <q-item
v-for="item in menuItems" v-for="item in navItems"
:key="item.to" :key="item.to"
:to="item.to" clickable
class="flex items-center gap-4 px-4 py-3 rounded-2xl transition-all group relative nav-item" v-ripple
:class="route.path === item.to ? 'active' : 'text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-200'" @click="handleNavigate(item.to)"
class="rounded-xl mb-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-white/5"
:class="{ 'sidebar-item--active': $route.path === item.to || ($route.path === '/dashboard' && item.to === '/dashboard') }"
> >
<q-icon :name="item.icon" size="24px" class="transition-colors" /> <q-item-section avatar>
<span class="font-bold text-[15px]">{{ item.label }}</span> <q-icon :name="item.icon" size="22px" />
</q-item-section>
<!-- วบงชหนาปจจ (Active Indicator) --> <q-item-section>
<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> <q-item-label class="font-bold text-sm">{{ $t(item.labelKey) }}</q-item-label>
</NuxtLink> </q-item-section>
</div> </q-item>
</q-list>
<!-- หมวดหมญช (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>
<div class="space-y-1">
<NuxtLink
v-for="item in accountItems"
:key="item.to"
:to="item.to"
class="flex items-center gap-4 px-4 py-3 rounded-2xl transition-all group nav-item"
:class="route.path === item.to ? 'active' : 'text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-200'"
>
<q-icon :name="item.icon" size="24px" />
<span class="font-bold text-[15px]">{{ item.label }}</span>
</NuxtLink>
<!-- มออกจากระบบ (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"
>
<q-icon name="logout" size="24px" class="group-hover:translate-x-1 transition-transform" />
<span>{{ $t('userMenu.logout') }}</span>
</button>
</div>
<q-space />
<!-- การดโปรโมช (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>
<p class="text-[11px] text-slate-500 dark:text-slate-400 mb-4">{{ $t('sidebar.promoSubtitle') }}</p>
<q-btn
unelevated
class="full-width rounded-xl bg-blue-600 hover:bg-blue-700 text-white font-bold py-2.5 no-caps transition-all active:scale-95 text-xs shadow-md shadow-blue-500/20"
@click="navigateTo('/browse/discovery')"
>
{{ $t('sidebar.learnMore') }}
</q-btn>
</div>
<!-- การตกแตงพนหลงแบบจางๆ (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> </div>
</template> </template>
<style scoped> <style scoped>
.nav-item.active { .sidebar-item--active {
background: #EFF6FF; background: #eff6ff !important; /* blue-50 */
color: #2563EB; color: #1d4ed8 !important; /* blue-700 */
position: relative;
} }
.dark .nav-item.active { .sidebar-item--active::before {
background: rgba(37,99,235,0.1); content: '';
color: #60A5FA; position: absolute;
left: 0;
top: 15%;
height: 70%;
width: 4px;
background: #2563eb;
border-radius: 0 4px 4px 0;
} }
.nav-item.active .q-icon { /* Dark Mode Active State Enhancement */
color: #2563EB; .dark .sidebar-item--active {
background: rgba(59, 130, 246, 0.12) !important;
color: #60a5fa !important; /* blue-400 */
} }
.dark .nav-item.active .q-icon { .dark .sidebar-item--active .q-icon {
color: #60A5FA; color: #60a5fa !important; /* blue-400 */
}
.dark .sidebar-item--active::before {
background: #3b82f6;
} }
</style> </style>

View file

@ -1,48 +1,56 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file LandingFooter.vue * @file LandingFooter.vue
* @description วนทายของหนาแรก (Footer component for the landing page) * @description Footer component for the landing page
*/ */
</script> </script>
<template> <template>
<footer class="bg-white pt-16 pb-8 border-t border-slate-200"> <footer class="bg-slate-50 pt-16 pb-8 border-t border-slate-200">
<div class="container mx-auto px-6 md:px-12"> <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"> <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"> <div class="space-y-6">
<NuxtLink to="/" class="flex items-center gap-3 group"> <div class="flex items-center gap-3">
<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"> <div class="w-12 h-12 rounded-xl bg-blue-600 flex items-center justify-center text-white font-black shadow-lg shadow-blue-600/30 shrink-0">
<q-icon name="o_school" size="24px" /> E
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="font-bold text-lg leading-none tracking-tight transition-colors text-slate-900"> <span class="font-black text-lg leading-none tracking-tight text-slate-900">E-Learning</span>
EduLearn <span class="text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500">Platform</span>
</span> </div>
<span class="text-[10px] text-slate-500 font-semibold uppercase tracking-[0.4em] leading-none mt-0.5 transition-colors">
Platform
</span>
</div> </div>
</NuxtLink>
<p class="text-slate-500 text-sm leading-relaxed max-w-xs"> <p class="text-slate-500 text-sm leading-relaxed max-w-xs">
แพลตฟอรมการเรยนรออนไลนงเนนการพฒนาทกษะดลสำหรบคนรนใหม เรยนรไดกท กเวลา บผเชยวชาญตวจร แพลตฟอรมการเรยนรออนไลนงเนนการพฒนาทกษะดลสำหรบคนรนใหม เรยนรไดกท กเวลา บผเชยวชาญตวจร
</p> </p>
<div class="flex gap-3">
<a href="#" class="w-9 h-9 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 hover:bg-blue-600 hover:text-white hover:border-blue-600 transition-all">
<q-icon name="facebook" size="18px" />
</a>
<a href="#" class="w-9 h-9 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 hover:bg-sky-400 hover:text-white hover:border-sky-400 transition-all">
<q-icon name="flutter_dash" size="18px" />
</a>
<a href="#" class="w-9 h-9 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 hover:bg-pink-600 hover:text-white hover:border-pink-600 transition-all">
<q-icon name="camera_alt" size="18px" />
</a>
</div>
</div> </div>
<!-- งกางๆ (Links) --> <!-- Links -->
<div class="lg:pl-8"> <div class="lg:pl-8">
<h4 class="font-bold text-slate-900 mb-6 text-base tracking-tight">คอรสเรยน</h4> <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"> <ul class="space-y-3 text-sm text-slate-500">
<li class=""><NuxtLink to="/browse" class="hover:text-blue-600 transition-colors inline-block">คอรสทงหมด</NuxtLink></li> <li><NuxtLink to="/browse" class="hover:text-blue-600 transition-colors inline-block">คอรสทงหมด</NuxtLink></li>
<li><NuxtLink to="/browse/recommended" class="hover:text-blue-600 transition-colors inline-block">คอรสแนะนำ</NuxtLink></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">โปรโมช</a></li> <li><a href="#" class="hover:text-blue-600 transition-colors inline-block">โปรโมช</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">สำหรบองคกร</a></li> <li><a href="#" class="hover:text-blue-600 transition-colors inline-block">สำหรบองคกร</a></li>
</ul> </ul>
</div> </div>
<!-- การสนบสนนผใช (Support) --> <!-- Support -->
<div> <div>
<h4 class="font-bold text-slate-900 mb-6 text-base">วยเหล</h4> <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"> <ul class="space-y-3 text-sm text-slate-500">
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">คำถามทพบบอย (FAQ)</a></li> <li><a href="#" class="hover:text-blue-600 transition-colors inline-block">คำถามทพบบอย (FAQ)</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">การใชงาน</a></li> <li><a href="#" class="hover:text-blue-600 transition-colors inline-block">การใชงาน</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">เงอนไขการใชงาน</a></li> <li><a href="#" class="hover:text-blue-600 transition-colors inline-block">เงอนไขการใชงาน</a></li>
@ -50,46 +58,41 @@
</ul> </ul>
</div> </div>
<!-- อมลการตดต (Contact) --> <!-- Contact -->
<div class="space-y-6"> <div>
<h4 class="font-bold text-slate-900 text-base">ดตอเรา</h4> <h4 class="font-bold text-slate-900 mb-6 text-base">ดตอเรา</h4>
<div class="flex flex-col gap-5"> <ul class="space-y-4 text-sm text-slate-500">
<!-- สถานท (Location) --> <li class="flex items-start gap-3">
<div class="flex flex-row items-start gap-4 flex-nowrap"> <div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center shrink-0 text-blue-600 mt-[-2px]">
<q-icon name="o_location_on" size="20px" color="slate-800" /> <q-icon name="location_on" size="18px" />
<div class="flex flex-col gap-1 min-w-0"> </div>
<span class="font-semibold text-slate-800 text-sm leading-tight">Bronco Hourse</span> <span class="leading-relaxed">123 อาคารสยามทาวเวอร 15 เขตปทมว กรงเทพฯ 10330</span>
<p class="text-slate-500 text-[11px] leading-relaxed"> </li>
74/2 Wiang Kaew Road, Tambon Si Phum, Amphoe Mueang Chiang Mai, Chang Wat Chiang Mai 50200 <li class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center shrink-0 text-blue-600">
<q-icon name="phone" size="18px" />
</div>
<span>02-123-4567</span>
</li>
<li class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center shrink-0 text-blue-600">
<q-icon name="email" size="18px" />
</div>
<span>support@elearning.com</span>
</li>
</ul>
</div>
</div>
<div class="pt-8 border-t border-slate-200 flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-sm text-slate-500 text-center md:text-left">
© {{ new Date().getFullYear() }} E-Learning Platform. All rights reserved.
</p> </p>
<div class="flex gap-6 text-sm font-medium text-slate-500">
<a href="#" class="hover:text-blue-600 transition-colors">Privacy Policy</a>
<a href="#" class="hover:text-blue-600 transition-colors">Terms of Service</a>
</div> </div>
</div> </div>
<!-- เบอรโทรศพท (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 transition-colors truncate">
052-076-025
</a>
</div>
<!-- เมล (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 transition-colors truncate">
info@chamomind.com
</a>
</div>
</div>
</div>
</div>
<!-- แถบดานลางสำหรบสงวนลขสทธ (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
</p>
</div>
</div> </div>
</footer> </footer>
</template> </template>

View file

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

View file

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

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file PasswordChangeForm.vue * @file PasswordChangeForm.vue
* @description ฟอรมสำหรบเปลยนรหสผานของผใช (From for changing user password) * @description From for changing user password
*/ */
const props = defineProps<{ const props = defineProps<{
@ -36,8 +36,8 @@ const showConfirmPassword = ref(false);
<template> <template>
<div :class="[!flat ? 'card-premium p-6 md:p-8' : '']" class="h-fit"> <div :class="[!flat ? 'card-premium p-6 md:p-8' : '']" class="h-fit">
<div v-if="!flat" class="flex items-center gap-3 mb-8"> <div v-if="!flat" class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center"> <div class="w-10 h-10 rounded-xl bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
<q-icon name="lock" class="text-blue-600 dark:text-blue-400 text-xl" /> <q-icon name="lock" class="text-amber-600 dark:text-amber-400 text-xl" />
</div> </div>
<h2 class="text-xl font-black text-slate-900 dark:text-white"> <h2 class="text-xl font-black text-slate-900 dark:text-white">
{{ $t('profile.security') }} {{ $t('profile.security') }}
@ -118,8 +118,8 @@ const showConfirmPassword = ref(false);
type="submit" type="submit"
unelevated unelevated
rounded rounded
class="w-full py-3 font-bold text-base shadow-lg shadow-blue-500/20" class="w-full py-3 font-bold text-base shadow-lg shadow-amber-500/20"
style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white;" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white;"
:label="$t('profile.changePasswordBtn')" :label="$t('profile.changePasswordBtn')"
:loading="loading" :loading="loading"
/> />
@ -130,12 +130,7 @@ const showConfirmPassword = ref(false);
<style scoped> <style scoped>
.card-premium { .card-premium {
background-color: white; @apply bg-white dark:bg-[#1e293b] border-slate-200 dark:border-white/5;
border-color: #e2e8f0;
}
:global(.dark) .card-premium {
background-color: #1e293b;
border-color: rgba(255, 255, 255, 0.05);
border-radius: 1.5rem; border-radius: 1.5rem;
border-width: 1px; border-width: 1px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05); box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);

View file

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

View file

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

View file

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

View file

@ -1,4 +1,45 @@
import type { User, LoginResponse, RegisterPayload } from '@/types/auth'
// Interface สำหรับข้อมูลผู้ใช้งาน (User)
interface User {
id: number
username: string
email: string
email_verified_at?: string | null
created_at?: string
updated_at?: string
role: {
code: string // เช่น 'STUDENT', 'INSTRUCTOR', 'ADMIN'
name: { th: string; en: string }
}
profile?: {
prefix: { th: string; en: string }
first_name: string
last_name: string
phone: string | null
avatar_url: string | null
}
}
// Interface สำหรับข้อมูลตอบกลับตอน Login
interface loginResponse {
token: string
refreshToken: string
user: User
profile: User['profile']
}
// Interface สำหรับข้อมูลที่ใช้ลงทะเบียน
interface RegisterPayload {
username: string
email: string
password: string
first_name: string
last_name: string
prefix: { th: string; en: string }
phone: string
}
// ========================================== // ==========================================
// Composable: useAuth // Composable: useAuth

View file

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

View file

@ -1,14 +1,151 @@
import type { // Interface สำหรับข้อมูลคอร์สเรียน (Public Course Data)
Course, export interface Course {
CourseResponse, id: number
SingleCourseResponse, title: string | { th: string; en: string } // รองรับ 2 ภาษา
EnrolledCourse, slug: string
EnrolledCourseResponse, description: string | { th: string; en: string }
QuizAnswerSubmission, thumbnail_url: string
QuizSubmitRequest, price: string
QuizResult, is_free: boolean
Certificate original_price?: string
} from '@/types/course' have_certificate: boolean
status: string // DRAFT, PUBLISHED
category_id: number
created_at?: string
updated_at?: string
created_by?: number
updated_by?: number
approved_at?: string
approved_by?: number
rejection_reason?: string
enrolled?: boolean
total_lessons?: number
rating?: string
lessons?: number | string
levelType?: 'neutral' | 'warning' | 'success' // ใช้สำหรับการแสดงผล Badge ระดับความยาก (Frontend Logic)
// โครงสร้างบทเรียน (Chapters & Lessons)
chapters?: {
id: number
title: string | { th: string; en: string }
lessons: {
id: number
title: string | { th: string; en: string }
duration_minutes: number
video_url?: string
}[]
}[]
// ข้อมูลผู้สอนและเจ้าของคอร์ส
creator?: {
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
avatar_url: string
}
}
instructors?: {
user_id: number
is_primary: boolean
user: {
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
avatar_url: string
}
}
}[]
}
interface CourseResponse {
code: number
message: string
data: Course[]
total: number
page?: number
limit?: number
totalPages?: number
}
interface SingleCourseResponse {
code: number
message: string
data: Course
}
// Interface สำหรับคอร์สที่ผู้ใช้ลงทะเบียนเรียนแล้ว (My Course)
export interface EnrolledCourse {
id: number
course_id: number
course: Course
status: 'ENROLLED' | 'IN_PROGRESS' | 'COMPLETED' | 'DROPPED'
progress_percentage: number
enrolled_at: string
started_at?: string
completed_at?: string
last_accessed_at?: string
}
interface EnrolledCourseResponse {
code: number
message: string
data: EnrolledCourse[]
total: number
page: number
limit: number
}
// Interface สำหรับการส่งคำตอบแบบทดสอบ (Quiz Submission)
export interface QuizAnswerSubmission {
question_id: number
choice_id: number
}
export interface QuizSubmitRequest {
answers: QuizAnswerSubmission[]
}
// Interface สำหรับผลลัพธ์การสอบ (Quiz Result)
export interface QuizResult {
answers_review: {
score: number
is_correct: boolean
correct_choice_id: number
selected_choice_id: number
question_id: number
}[]
completed_at: string
started_at: string
attempt_number: number
passing_score: number
is_passed: boolean
correct_answers: number
total_questions: number
total_score: number
score: number
quiz_id: number
attempt_id: number
}
// Interface สำหรับ Certificate
export interface Certificate {
certificate_id: number
course_id: number
course_title: {
en: string
th: string
}
issued_at: string
download_url: string
}
// ========================================== // ==========================================
// Composable: useCourse // Composable: useCourse
@ -34,7 +171,6 @@ export const useCourse = () => {
category_id?: number; category_id?: number;
page?: number; page?: number;
limit?: number; limit?: number;
search?: string;
random?: boolean; random?: boolean;
is_recommended?: boolean; is_recommended?: boolean;
forceRefresh?: boolean forceRefresh?: boolean
@ -57,7 +193,6 @@ export const useCourse = () => {
if (apiParams.category_id) queryParams.append('category_id', apiParams.category_id.toString()) if (apiParams.category_id) queryParams.append('category_id', apiParams.category_id.toString())
if (apiParams.page) queryParams.append('page', apiParams.page.toString()) if (apiParams.page) queryParams.append('page', apiParams.page.toString())
if (apiParams.limit) queryParams.append('limit', apiParams.limit.toString()) if (apiParams.limit) queryParams.append('limit', apiParams.limit.toString())
if (apiParams.search) queryParams.append('search', apiParams.search)
if (apiParams.random !== undefined) queryParams.append('random', apiParams.random.toString()) if (apiParams.random !== undefined) queryParams.append('random', apiParams.random.toString())
if (apiParams.is_recommended !== undefined) queryParams.append('is_recommended', apiParams.is_recommended.toString()) if (apiParams.is_recommended !== undefined) queryParams.append('is_recommended', apiParams.is_recommended.toString())

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,43 +0,0 @@
/**
* @file landing.ts
* @description (Static data) Landing page
*/
export const CATEGORY_CARDS = [
{
title: 'โปรแกรมมิ่ง',
desc: 'เชี่ยวชาญการเขียนโค้ดและพัฒนาซอฟต์แวร์',
icon: 'o_code',
slug: 'programming',
},
{
title: 'การออกแบบ',
desc: 'ทักษะ UI/UX และการออกแบบระดับมือโปร',
icon: 'o_palette',
slug: 'design',
},
{
title: 'ธุรกิจ',
desc: 'ทักษะการจัดการและความเป็นผู้นำสากล',
icon: 'o_business_center',
slug: 'business',
}
]
export const WHY_CHOOSE_US = [
{
title: 'ผู้สอนเชี่ยวชาญ',
desc: 'เรียนรู้จากผู้นำในอุตสาหกรรมที่มีประสบการณ์การทำงานหลายปีในบริษัทเทคโนโลยีชั้นนำระดับโลก',
icon: 'o_groups',
},
{
title: 'การเรียนรู้ที่ยืดหยุ่น',
desc: 'เรียนตามจังหวะของคุณเอง ได้ทุกที่ทุกเวลา เข้าถึงเนื้อหาคอร์สที่สมัครเรียนได้ตลอดชีพ',
icon: 'o_schedule',
},
{
title: 'ประกาศนียบัตรเมื่อเรียนจบ',
desc: 'รับวุฒิบัตรที่เป็นที่ยอมรับเพื่อเสริมพอร์ตโฟลิโอระดับมืออาชีพของคุณและแชร์ลง LinkedIn ได้โดยตรง',
icon: 'o_verified',
}
]

View file

@ -24,8 +24,7 @@
"emptyLibraryDesc": "Start learning new things today. Browse interesting courses to develop your skills.", "emptyLibraryDesc": "Start learning new things today. Browse interesting courses to develop your skills.",
"viewAllCourses": "View All Courses", "viewAllCourses": "View All Courses",
"recommendedCourses": "Recommended Courses", "recommendedCourses": "Recommended Courses",
"noRecommended": "No recommended courses found", "noRecommended": "No recommended courses found"
"moreCourses": "More Courses"
}, },
"menu": { "menu": {
"continueLearning": "Continue Learning", "continueLearning": "Continue Learning",
@ -88,14 +87,10 @@
"overview": "Home", "overview": "Home",
"myCourses": "My Courses", "myCourses": "My Courses",
"browseCourses": "Browse Courses", "browseCourses": "Browse Courses",
"onlineCourses": "All Courses", "onlineCourses": "Online Courses",
"recommendedCourses": "Recommended Courses", "recommendedCourses": "Recommended Courses",
"announcements": "Announcements", "announcements": "Announcements",
"profile": "My Profile", "profile": "My Profile"
"accountGroup": "Account",
"promoTitle": "Find the right course",
"promoSubtitle": "Level up your skills",
"learnMore": "Learn More"
}, },
"discovery": { "discovery": {
"title": "All Courses", "title": "All Courses",
@ -103,9 +98,6 @@
"sortRecent": "Sort by: Recent", "sortRecent": "Sort by: Recent",
"sortPopular": "Popular", "sortPopular": "Popular",
"categoryTitle": "Categories", "categoryTitle": "Categories",
"design": "Design",
"programming": "Programming",
"business": "Business",
"showMore": "Show More", "showMore": "Show More",
"showLess": "Show Less", "showLess": "Show Less",
"emptyTitle": "No courses found", "emptyTitle": "No courses found",
@ -115,22 +107,15 @@
"backToCatalog": "Back to Catalog", "backToCatalog": "Back to Catalog",
"selectable": "Selected", "selectable": "Selected",
"foundTotal": "Found Total", "foundTotal": "Found Total",
"items": "items", "items": "items"
"subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search"
}, },
"myCourses": { "myCourses": {
"title": "My Courses",
"subtitle": "Track your progress and continue learning from where you left off",
"searchPlaceholder": "Search my courses...",
"filterAll": "All", "filterAll": "All",
"filterProgress": "In Progress", "filterProgress": "In Progress",
"filterCompleted": "Completed", "filterCompleted": "Completed",
"emptyTitle": "No courses in this category", "emptyTitle": "No courses in this category",
"emptyDesc": "You don't have any courses here yet. Browse our catalog to find interesting courses.", "emptyDesc": "You don't have any courses here yet. Browse our catalog to find interesting courses.",
"goToDiscovery": "Go to Courses", "goToDiscovery": "Go to Courses"
"searchNoResult": "No matching courses found",
"searchNoResultDesc": "Try changing category or your search term"
}, },
"enrollment": { "enrollment": {
"successTitle": "Enrollment Successful!", "successTitle": "Enrollment Successful!",
@ -159,7 +144,7 @@
"accountDetails": "Account Details", "accountDetails": "Account Details",
"editPersonalDesc": "Edit Personal Information", "editPersonalDesc": "Edit Personal Information",
"yourAvatar": "Your Profile Photo", "yourAvatar": "Your Profile Photo",
"avatarHint": "Only JPG, PNG", "avatarHint": "PNG, JPG only",
"uploadNew": "Upload New Photo", "uploadNew": "Upload New Photo",
"changeAvatar": "Change Profile Photo", "changeAvatar": "Change Profile Photo",
"removeAvatar": "Remove Profile Photo", "removeAvatar": "Remove Profile Photo",
@ -190,15 +175,7 @@
"emailVerified": "Email Verified", "emailVerified": "Email Verified",
"myCertificates": "My Certificates", "myCertificates": "My Certificates",
"viewCertificate": "View Certificate", "viewCertificate": "View Certificate",
"issuedAt": "Issued at", "issuedAt": "Issued at"
"publicInfo": "Information visible to the public on the platform",
"uploading": "Uploading...",
"selectPrefix": "Select Prefix",
"verifyNow": "Click to verify email",
"verifying": "Sending...",
"saving": "Saving...",
"securitySubtitle": "Manage password and account access",
"password": "Password"
}, },
"userMenu": { "userMenu": {
"home": "Home", "home": "Home",
@ -221,9 +198,7 @@
"emailVerifiedDesc": "Your account has been successfully verified.", "emailVerifiedDesc": "Your account has been successfully verified.",
"invalidToken": "Invalid verification token", "invalidToken": "Invalid verification token",
"tokenExpired": "Token expired or invalid", "tokenExpired": "Token expired or invalid",
"logout": "Log Out", "logout": "Log Out"
"logoutConfirmTitle": "Confirm Logout",
"logoutConfirmMessage": "Are you sure you want to log out of the system?"
}, },
"language": { "language": {
"label": "Language / ภาษา", "label": "Language / ภาษา",
@ -234,7 +209,6 @@
"newBadge": "New", "newBadge": "New",
"popularBadge": "Popular", "popularBadge": "Popular",
"save": "Save", "save": "Save",
"saveChanges": "Save Changes",
"ok": "OK", "ok": "OK",
"close": "Close", "close": "Close",
"cancel": "Cancel", "cancel": "Cancel",
@ -248,9 +222,7 @@
"backToHome": "Back to Home", "backToHome": "Back to Home",
"error": "Error", "error": "Error",
"loading": "Loading", "loading": "Loading",
"items": "Items", "items": "Items"
"student": "Student",
"latest": "Latest"
}, },
"classroom": { "classroom": {
"backToDashboard": "Back to My Courses", "backToDashboard": "Back to My Courses",
@ -319,16 +291,5 @@
"statusNotStarted": "Not Started", "statusNotStarted": "Not Started",
"alertIncomplete": "Please answer all questions", "alertIncomplete": "Please answer all questions",
"yourAnswer": "Your Answer" "yourAnswer": "Your Answer"
},
"footer": {
"location": "LOCATION",
"connectWithUs": "CONNECT WITH US",
"broncoHorse": "Bronco Hourse",
"address": "123 อาคารสยามทาวเวอร์ ชั้น 15 เขตปทุมวัน กรุงเทพฯ 10330",
"emailLabel": "Email",
"emailValue": "info{'@'}chamomind.com",
"telLabel": "Tel",
"telValue": "02-123-4567",
"copyright": "© 2026 E-Learning Platform. All rights reserved."
} }
} }

View file

@ -24,8 +24,7 @@
"emptyLibraryDesc": "เริ่มเรียนรู้สิ่งใหม่ๆ วันนี้ เลือกดูคอร์สเรียนที่น่าสนใจเพื่อพัฒนาทักษะของคุณ", "emptyLibraryDesc": "เริ่มเรียนรู้สิ่งใหม่ๆ วันนี้ เลือกดูคอร์สเรียนที่น่าสนใจเพื่อพัฒนาทักษะของคุณ",
"viewAllCourses": "ดูคอร์สเรียนทั้งหมด", "viewAllCourses": "ดูคอร์สเรียนทั้งหมด",
"recommendedCourses": "คอร์สแนะนำ", "recommendedCourses": "คอร์สแนะนำ",
"noRecommended": "ไม่พบข้อมูลคอร์สแนะนำ", "noRecommended": "ไม่พบข้อมูลคอร์สแนะนำ"
"moreCourses": "คอร์สเพิ่มเติม"
}, },
"menu": { "menu": {
"continueLearning": "เรียนต่อจากเดิม", "continueLearning": "เรียนต่อจากเดิม",
@ -88,14 +87,10 @@
"overview": "หน้าหลัก", "overview": "หน้าหลัก",
"myCourses": "คอร์สของฉัน", "myCourses": "คอร์สของฉัน",
"browseCourses": "ค้นหาคอร์ส", "browseCourses": "ค้นหาคอร์ส",
"onlineCourses": "คอร์สเรียนทั้งหมด", "onlineCourses": "คอร์สออนไลน์",
"recommendedCourses": "คอร์สเรียนแนะนำ", "recommendedCourses": "คอร์สเรียนออนไลน์แนะนำ",
"announcements": "ข่าวประกาศ", "announcements": "ข่าวประกาศ",
"profile": "บัญชีผู้ใช้", "profile": "บัญชีผู้ใช้"
"accountGroup": "บัญชี",
"promoTitle": "ค้นหาคอร์สที่ใช่",
"promoSubtitle": "ยกระดับทักษะของคุณ",
"learnMore": "เรียนรู้เพิ่มเติม"
}, },
"discovery": { "discovery": {
"title": "รายการคอร์สทั้งหมด", "title": "รายการคอร์สทั้งหมด",
@ -103,9 +98,6 @@
"sortRecent": "เรียงตาม: ล่าสุด", "sortRecent": "เรียงตาม: ล่าสุด",
"sortPopular": "ยอดนิยม", "sortPopular": "ยอดนิยม",
"categoryTitle": "หมวดหมู่", "categoryTitle": "หมวดหมู่",
"design": "การออกแบบ",
"programming": "การเขียนโปรแกรม",
"business": "ธุรกิจ",
"showMore": "แสดงเพิ่มเติม", "showMore": "แสดงเพิ่มเติม",
"showLess": "แสดงน้อยลง", "showLess": "แสดงน้อยลง",
"emptyTitle": "ไม่พบผลการค้นหา", "emptyTitle": "ไม่พบผลการค้นหา",
@ -115,22 +107,15 @@
"backToCatalog": "กลับหน้ารายการคอร์ส", "backToCatalog": "กลับหน้ารายการคอร์ส",
"selectable": "รายการที่เลือก", "selectable": "รายการที่เลือก",
"foundTotal": "พบทั้งหมด", "foundTotal": "พบทั้งหมด",
"items": "รายการ", "items": "รายการ"
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา"
}, },
"myCourses": { "myCourses": {
"title": "คอร์สของฉัน",
"subtitle": "ติดตามความคืบหน้าและเรียนรู้ต่อจากจุดที่ค้างไว้",
"searchPlaceholder": "ค้นหาชื่อคอร์สของฉัน...",
"filterAll": "ทั้งหมด", "filterAll": "ทั้งหมด",
"filterProgress": "กำลังเรียน", "filterProgress": "กำลังเรียน",
"filterCompleted": "เรียนจบแล้ว", "filterCompleted": "เรียนจบแล้ว",
"emptyTitle": "ยังไม่มีคอร์สในหมวดหมู่นี้", "emptyTitle": "ยังไม่มีคอร์สในหมวดหมู่นี้",
"emptyDesc": "คุณยังไม่มีคอร์สเรียนในส่วนนี้ ลองเลือกดูคอร์สที่น่าสนใจในระบบของเรา", "emptyDesc": "คุณยังไม่มีคอร์สเรียนในส่วนนี้ ลองเลือกดูคอร์สที่น่าสนใจในระบบของเรา",
"goToDiscovery": "ไปที่รายการคอร์ส", "goToDiscovery": "ไปที่รายการคอร์ส"
"searchNoResult": "ไม่พบคอร์สที่สอดคล้อง",
"searchNoResultDesc": "ลองเปลี่ยนหมวดหมู่หรือคำค้นหาของคุณ"
}, },
"enrollment": { "enrollment": {
"successTitle": "ลงทะเบียนสำเร็จ!", "successTitle": "ลงทะเบียนสำเร็จ!",
@ -159,7 +144,7 @@
"accountDetails": "รายละเอียดบัญชี", "accountDetails": "รายละเอียดบัญชี",
"editPersonalDesc": "แก้ไขข้อมูลส่วนตัว", "editPersonalDesc": "แก้ไขข้อมูลส่วนตัว",
"yourAvatar": "รูปโปรไฟล์ของคุณ", "yourAvatar": "รูปโปรไฟล์ของคุณ",
"avatarHint": "เฉพาะไฟล์ JPG, PNG", "avatarHint": "เฉพาะไฟล์ png , jpg",
"uploadNew": "อัพโหลดรูปโปรไฟล์", "uploadNew": "อัพโหลดรูปโปรไฟล์",
"changeAvatar": "เปลี่ยนรูปโปรไฟล์", "changeAvatar": "เปลี่ยนรูปโปรไฟล์",
"removeAvatar": "ลบรูปโปรไฟล์", "removeAvatar": "ลบรูปโปรไฟล์",
@ -190,15 +175,7 @@
"emailVerified": "ยืนยันอีเมลเสร็จสิ้น", "emailVerified": "ยืนยันอีเมลเสร็จสิ้น",
"myCertificates": "ประกาศนียบัตรของฉัน", "myCertificates": "ประกาศนียบัตรของฉัน",
"viewCertificate": "ดูประกาศนียบัตร", "viewCertificate": "ดูประกาศนียบัตร",
"issuedAt": "ออกเมื่อ", "issuedAt": "ออกเมื่อ"
"publicInfo": "ข้อมูลที่แสดงต่อสาธารณะบนแพลตฟอร์ม",
"uploading": "กำลังอัปโหลด...",
"selectPrefix": "เลือกคำนำหน้า",
"verifyNow": "คลิกเพื่อยืนยันอีเมล",
"verifying": "กำลังส่ง...",
"saving": "กำลังบันทึก...",
"securitySubtitle": "จัดการรหัสผ่านและการเข้าถึงบัญชี",
"password": "รหัสผ่าน"
}, },
"userMenu": { "userMenu": {
"home": "หน้าหลัก", "home": "หน้าหลัก",
@ -209,7 +186,7 @@
"logout": "ออกจากระบบ" "logout": "ออกจากระบบ"
}, },
"landing": { "landing": {
"allCourses": "คอร์สเรียนทั้งหมด", "allCourses": "คอร์สทั้งหมด",
"discovery": "ค้นพบ", "discovery": "ค้นพบ",
"goToDashboard": "เข้าสู่หน้าจัดการเรียน" "goToDashboard": "เข้าสู่หน้าจัดการเรียน"
}, },
@ -221,9 +198,7 @@
"emailVerifiedDesc": "บัญชีของคุณได้รับการยืนยันเรียบร้อยแล้ว", "emailVerifiedDesc": "บัญชีของคุณได้รับการยืนยันเรียบร้อยแล้ว",
"invalidToken": "Token ยืนยันตัวตนไม่ถูกต้อง", "invalidToken": "Token ยืนยันตัวตนไม่ถูกต้อง",
"tokenExpired": "Token หมดอายุหรือล้มเหลว", "tokenExpired": "Token หมดอายุหรือล้มเหลว",
"logout": "ออกจากระบบ", "logout": "ออกจากระบบ"
"logoutConfirmTitle": "ยืนยันการออกจากระบบ",
"logoutConfirmMessage": "คุณแน่ใจหรือไม่ว่าต้องการออกจากระบบ?"
}, },
"language": { "language": {
"label": "ภาษา / Language", "label": "ภาษา / Language",
@ -234,7 +209,6 @@
"newBadge": "ใหม่", "newBadge": "ใหม่",
"popularBadge": "ยอดนิยม", "popularBadge": "ยอดนิยม",
"save": "บันทึก", "save": "บันทึก",
"saveChanges": "บันทึกการเปลี่ยนแปลง",
"ok": "ตกลง", "ok": "ตกลง",
"close": "ปิด", "close": "ปิด",
"cancel": "ยกเลิก", "cancel": "ยกเลิก",
@ -248,9 +222,7 @@
"backToHome": "กลับสู่หน้าหลัก", "backToHome": "กลับสู่หน้าหลัก",
"error": "เกิดข้อผิดพลาด", "error": "เกิดข้อผิดพลาด",
"loading": "กำลังโหลด", "loading": "กำลังโหลด",
"items": "รายการ", "items": "รายการ"
"student": "นักเรียน",
"latest": "ล่าสุด"
}, },
"classroom": { "classroom": {
"backToDashboard": "กลับไปคอร์สของฉัน", "backToDashboard": "กลับไปคอร์สของฉัน",
@ -319,16 +291,5 @@
"statusNotStarted": "ยังไม่ทำ", "statusNotStarted": "ยังไม่ทำ",
"alertIncomplete": "กรุณาเลือกคำตอบให้ครบทุกข้อ", "alertIncomplete": "กรุณาเลือกคำตอบให้ครบทุกข้อ",
"yourAnswer": "คำตอบของคุณ" "yourAnswer": "คำตอบของคุณ"
},
"footer": {
"location": "สถานที่ตั้ง",
"connectWithUs": "ติดต่อเรา",
"broncoHorse": "Bronco Hourse",
"address": "123 อาคารสยามทาวเวอร์ ชั้น 15 เขตปทุมวัน กรุงเทพฯ 10330",
"emailLabel": "อีเมล",
"emailValue": "info{'@'}chamomind.com",
"telLabel": "เบอร์โทรศัพท์",
"telValue": "02-123-4567",
"copyright": "© 2026 E-Learning Platform. All rights reserved."
} }
} }

View file

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

View file

@ -1,153 +1,37 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file dashboard-index.vue * @file dashboard-index.vue
* @description เลยเอาตสำหรบหนาแรกของ Dashboard (ไมแผงเมนานขางเพอเนนพนทเนอหา) * @description Layout for the Dashboard Index page, without the sidebar.
* ใชโครงสรางจาก Quasar QLayout เพอใหรองร Responsive * Uses Quasar QLayout for responsive structure.
*/ */
// Global // Initialize global theme management
useThemeMode() useThemeMode()
const { currentUser, logout } = useAuth() // No sidebar logic needed here as we are removing it
const { isDark, set: setTheme } = useThemeMode()
const rightDrawerOpen = ref(false)
const toggleRightDrawer = () => {
rightDrawerOpen.value = !rightDrawerOpen.value
}
</script> </script>
<template> <template>
<q-layout view="hHh lpR fFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50"> <q-layout view="hHh lpR fFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- แถบดานบนของโครงสราง (Header) --> <!-- Header -->
<q-header <q-header
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white" class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white"
> >
<AppHeader <AppHeader :showSidebarToggle="false" navType="learner" />
@toggleRightDrawer="toggleRightDrawer"
:showSidebarToggle="false"
navType="learner"
/>
</q-header> </q-header>
<!-- แถบลนชกมอถอหล (เมนรวมทกอยางเมอปดหนาจอ/กดไอคอนบนสดสวนเล) --> <!-- Sidebar Removed for this layout -->
<q-drawer
v-model="rightDrawerOpen"
side="right"
overlay
bordered
class="bg-white dark:!bg-[#0f172a]"
:width="300"
>
<div class="flex flex-col h-full bg-white dark:bg-[#0f172a]">
<!-- 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">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-black">E</div>
<span class="font-black text-lg text-slate-900 dark:text-white">E-Learning</span>
</div>
<q-btn flat round dense icon="close" class="text-slate-400" @click="rightDrawerOpen = false" />
</div>
<div class="flex items-center gap-4 py-2"> <!-- Main Content -->
<q-avatar size="64px" class="shadow-lg border-2 border-white dark:border-slate-700">
<img :src="currentUser?.photoURL || 'https://cdn.quasar.dev/img/avatar.png'" />
</q-avatar>
<div class="overflow-hidden">
<p class="font-bold text-slate-900 dark:text-white mb-0 truncate text-lg">
{{ currentUser?.firstName || 'Guest' }} {{ currentUser?.lastName || '' }}
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 truncate">{{ currentUser?.email || 'e-learning@platform.com' }}</p>
</div>
</div>
</div>
<!-- 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) -->
<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">
<q-item-section avatar><q-icon name="dashboard" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("sidebar.overview") }}</span></q-item-section>
</q-item>
<q-item to="/browse/discovery" 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">
<q-item-section avatar><q-icon name="explore" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("landing.allCourses") }}</span></q-item-section>
</q-item>
<q-item to="/dashboard/my-courses" 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">
<q-item-section avatar><q-icon name="school" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("sidebar.myCourses") || 'คอร์สเรียนของฉัน' }}</span></q-item-section>
</q-item>
<q-separator class="my-4 mx-6 opacity-50" />
<!-- เครองมอทวไปและการตงคาระบบ -->
<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 class="px-6 py-2">
<q-item-section avatar><q-icon name="language" size="22px" /></q-item-section>
<q-item-section>
<div class="flex items-center justify-between">
<span class="font-bold text-[14px]">ภาษา</span>
<LanguageSwitcher dense />
</div>
</q-item-section>
</q-item>
<!-- เปดปดโหมดม (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>
<div class="flex items-center justify-between">
<span class="font-bold text-[14px]">โหมดกลางค</span>
<q-toggle
:model-value="isDark"
@update:model-value="setTheme"
color="blue"
/>
</div>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('/dashboard/profile'); rightDrawerOpen = false" class="px-6 py-4">
<q-item-section avatar><q-icon name="person_outline" size="24px" /></q-item-section>
<q-item-section><span class="font-bold text-[15px]">ดการโปรไฟล</span></q-item-section>
</q-item>
</q-list>
</div>
<!-- 3. วนลางส เช อกเอาต หรอระบเวอรนระบบ -->
<div class="p-6 mt-auto border-t border-slate-100 dark:border-slate-800">
<q-btn
unelevated
class="full-width rounded-xl bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400 font-bold py-3 no-caps transition-all active:scale-95"
@click="logout"
>
<q-icon name="logout" size="20px" class="mr-2" />
ออกจากระบบ
</q-btn>
<div class="text-center mt-6">
<span class="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-300 dark:text-slate-600">E-Learning Platform v1.0</span>
</div>
</div>
</div>
</q-drawer>
<!-- หมายเหต: สำหรบหนาน จะไมไดการโชว Sidebar เปนลนชกซาย -->
<!-- นทแสดงเนอหาหล -->
<q-page-container> <q-page-container>
<q-page class="relative"> <q-page class="relative">
<slot /> <slot />
</q-page> </q-page>
</q-page-container> </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 <q-footer
v-if="$q.screen.lt.md" v-if="$q.screen.lt.md"
class="!bg-white dark:!bg-[#1e293b] text-primary" class="!bg-white dark:!bg-[#1e293b] text-primary"

View file

@ -1,66 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file default.vue * @file default.vue
* @description เลยเอาตหล (Master Layout) สำหรบผใชงานทเขาสระบบแล * @description Layout หลกสำหรบหนาเวบของผใช (Authenticated Users)
* ประกอบดวยแถบเมนานบน (Header), แถบเมนานขาง (Sidebar) และพนทเนอหา * Uses Quasar QLayout for responsive structure.
*/ */
// Initialize global theme management
useThemeMode() useThemeMode()
const leftDrawerOpen = ref(true) const leftDrawerOpen = ref(false)
const toggleLeftDrawer = () => { const toggleLeftDrawer = () => {
leftDrawerOpen.value = !leftDrawerOpen.value leftDrawerOpen.value = !leftDrawerOpen.value
} }
const route = useRoute() const route = useRoute()
// path (Sidebar) // Automatically hide sidebar for learner routes
const isDashboardRoute = computed(() => { const shouldHideSidebar = computed(() => {
const routes = ['/dashboard', '/browse', '/classroom', '/course'] const silentRoutes = ['/dashboard', '/browse', '/classroom', '/course']
return routes.some(r => route.path.startsWith(r)) return silentRoutes.some(r => route.path.startsWith(r))
}) })
</script> </script>
<template> <template>
<q-layout view="lHh Lpr lFf" class="bg-[#F8FAFC] dark:!bg-[#020617] text-slate-900 dark:!text-slate-50"> <q-layout view="hHh LpR lFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- Header -->
<!-- วนห (Header) -->
<q-header <q-header
class="bg-transparent text-slate-900 dark:!text-white border-none shadow-none" class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white"
> >
<AppHeader @toggleSidebar="toggleLeftDrawer" /> <AppHeader @toggleSidebar="toggleLeftDrawer" :showSidebarToggle="!shouldHideSidebar" />
</q-header> </q-header>
<!-- แถบเมนานขาง (Navigation Sidebar) --> <!-- Sidebar (Drawer) -->
<q-drawer <q-drawer
v-if="!shouldHideSidebar"
v-model="leftDrawerOpen" v-model="leftDrawerOpen"
show-if-above show-if-above
:width="280" :width="280"
side="left" class="bg-white dark:!bg-[#0f172a]"
bordered
class="bg-white dark:!bg-[#0f172a] border-none"
> >
<AppSidebar /> <AppSidebar />
</q-drawer> </q-drawer>
<!-- นทแสดงเนอหาหล (Main Content Area) --> <!-- Main Content -->
<q-page-container> <q-page-container>
<q-page class="px-3 py-6 md:p-8"> <q-page class="relative">
<div class="max-w-[1600px] mx-auto">
<slot /> <slot />
</div>
</q-page> </q-page>
</q-page-container> </q-page-container>
</q-layout> </q-layout>
</template> </template>
<style> <style>
/* Global Layout Adjustments */ /* Ensure fonts are applied */
.q-drawer--bordered { .font-inter {
border-right: 1px solid rgba(0,0,0,0.05) !important; font-family: var(--font-main);
}
.dark .q-drawer--bordered {
border-right: 1px solid rgba(255,255,255,0.05) !important;
} }
</style> </style>

View file

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

View file

@ -33,11 +33,8 @@ export default defineNuxtConfig({
// การตั้งค่า Quasar Framework // การตั้งค่า Quasar Framework
quasar: { quasar: {
iconSet: 'material-icons-outlined',
extras: { extras: {
fontIcons: [ fontIcons: ["material-icons"],
"material-icons",
"material-icons-outlined"] // ใช้ไอคอน Material Icons, material-icons-outlined
}, },
plugins: ["Notify", "Dialog"], // เปิดใช้ Plugin Notify และ Dialog plugins: ["Notify", "Dialog"], // เปิดใช้ Plugin Notify และ Dialog
config: { config: {
@ -69,7 +66,6 @@ export default defineNuxtConfig({
{ name: "viewport", content: "width=device-width, initial-scale=1" }, { name: "viewport", content: "width=device-width, initial-scale=1" },
], ],
link: [ link: [
{ rel: 'icon', type: 'image/png', href: '/img/logo.png' },
{ {
rel: "stylesheet", rel: "stylesheet",
// โหลด Font: Inter, Prompt, Sarabun // โหลด Font: Inter, Prompt, Sarabun

View file

@ -23,7 +23,7 @@ const isLoading = ref(false)
const rememberMe = ref(false) const rememberMe = ref(false)
const showPassword = ref(false) const showPassword = ref(false)
// // Form data model
const loginForm = reactive({ const loginForm = reactive({
email: '', email: '',
password: '' password: ''
@ -31,7 +31,7 @@ const loginForm = reactive({
type LoginField = keyof typeof loginForm type LoginField = keyof typeof loginForm
// (Validation Rules) // Validation rules definition
// (Validation Rules) // (Validation Rules)
const loginRules = { const loginRules = {
email: { 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 ไม่ถูกต้อง') { if (result.error === 'Email ไม่ถูกต้อง') {
errors.value.email = result.error // Role errors.value.email = result.error // Role mismatch case
} else { } else {
// ( , ) // Generic login failure (401, 404, etc.)
const msg = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง' const msg = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'
errors.value.email = msg errors.value.email = msg
errors.value.password = msg errors.value.password = msg
@ -147,7 +147,7 @@ onMounted(() => {
========================================== --> ========================================== -->
<div class="w-full max-w-[460px] relative z-10 slide-up"> <div class="w-full max-w-[460px] relative z-10 slide-up">
<!-- วนหวโปรไฟล / โลโก (Header / Logo) --> <!-- Header / Logo -->
<div class="text-center mb-8"> <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"> <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> <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"> <div class="bg-white rounded-[2rem] p-8 md:p-10 shadow-xl shadow-slate-200/50 border border-slate-100 relative overflow-hidden">
<!-- ฟอรมเขาสระบบ (Login Form) --> <!-- Form -->
<form @submit.prevent="handleLogin" class="flex flex-col gap-5"> <form @submit.prevent="handleLogin" class="flex flex-col gap-5">
<!-- องกรอกอเมล (Email Input) --> <!-- Email Input -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">เมล</label> <label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">เมล</label>
<div class="relative group"> <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> <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> </div>
<!-- องกรอกรหสผาน (Password Input) --> <!-- Password Input -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหสผาน</label> <label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหสผาน</label>
<div class="relative group"> <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> <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> </div>
<!-- วเลอกเพมเต (จดจำฉ, มรหสผาน) (Options) --> <!-- Options -->
<div class="flex items-center justify-between mt-1"> <div class="flex items-center justify-between mt-1">
<label class="flex items-center gap-2.5 cursor-pointer group select-none"> <label class="flex items-center gap-2.5 cursor-pointer group select-none">
<div class="relative flex items-center"> <div class="relative flex items-center">
@ -227,7 +227,7 @@ onMounted(() => {
</NuxtLink> </NuxtLink>
</div> </div>
<!-- มยนยนเขาสระบบ (Submit Button) --> <!-- Submit Button -->
<button <button
type="submit" type="submit"
:disabled="isLoading" :disabled="isLoading"
@ -237,24 +237,17 @@ onMounted(() => {
<div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> <div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
</button> </button>
<!-- กลองแนะนำบญชสำหรบทดสอบ (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">
<div class="text-base font-black text-slate-900 select-all cursor-copy hover:text-blue-600 transition-colors">
studentedtest@example.com
</div>
<div class="flex items-center gap-2">
<span class="text-[11px] font-black uppercase tracking-wider text-slate-600">Password:</span>
<span class="text-base font-black select-all cursor-copy hover:text-blue-600 transition-colors text-slate-900">admin123</span>
</div>
</div>
</div>
</form> </form>
<!-- งกสำหรบสมครสมาชกใหม (Register Link) --> <!-- Divider -->
<div class="text-center mt-8"> <div class="my-8 flex items-center gap-4">
<div class="h-px bg-slate-200 flex-1"></div>
<span class="text-slate-400 text-xs font-medium uppercase tracking-wider">หร</span>
<div class="h-px bg-slate-200 flex-1"></div>
</div>
<!-- Register Link -->
<div class="text-center">
<p class="text-slate-600 text-sm"> <p class="text-slate-600 text-sm">
งไมญชสมาช? งไมญชสมาช?
<NuxtLink to="/auth/register" class="font-bold text-blue-600 hover:text-blue-700 transition-colors ml-1"> <NuxtLink to="/auth/register" class="font-bold text-blue-600 hover:text-blue-700 transition-colors ml-1">
@ -265,7 +258,7 @@ onMounted(() => {
</div> </div>
<!-- งกอนกล (Back Link) --> <!-- Back Link -->
<div class="mt-8 text-center text-slate-500"> <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"> <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> กลบไปหนาแรก <span class="group-hover:-translate-x-1 transition-transform"></span> กลบไปหนาแรก
@ -276,7 +269,7 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
/* เอฟเฟกต์การเคลื่อนไหว (Animations) */ /* Animations */
@keyframes pulse-slow { @keyframes pulse-slow {
0%, 100% { opacity: 0.3; transform: scale(1); } 0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.15); } 50% { opacity: 0.5; transform: scale(1.15); }

View file

@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file discovery.vue * @file discovery.vue
* @description Course Discovery / Catalog Page matching Figma Desktop Layout. * @description Course Discovery / Catalog Page.
* Allows users to browse, filter, and view details of available courses.
* Includes a toggleable detailed view for course previews.
*/ */
definePageMeta({ definePageMeta({
@ -13,19 +15,11 @@ useHead({
title: "รายการคอร์ส - e-Learning", title: "รายการคอร์ส - e-Learning",
}); });
const { t, locale } = useI18n(); // ==========================================
const { currentUser } = useAuth(); // 1. State Management
const $q = useQuasar();
const { fetchCategories } = useCategory();
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse();
const showDetail = ref(false); const showDetail = ref(false);
const searchQuery = ref(""); const searchQuery = ref("");
const activeCategory = ref<number | 'all'>('all'); const selectedCategoryIds = ref<number[]>([]);
const viewMode = ref<'grid' | 'list'>('grid');
const sortBy = ref('ยอดนิยม');
const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด'];
const categories = ref<any[]>([]); const categories = ref<any[]>([]);
const courses = ref<any[]>([]); const courses = ref<any[]>([]);
const selectedCourse = ref<any>(null); const selectedCourse = ref<any>(null);
@ -34,43 +28,47 @@ const isLoading = ref(false);
const isLoadingDetail = ref(false); const isLoadingDetail = ref(false);
const isEnrolling = ref(false); const isEnrolling = ref(false);
// Pagination State
const currentPage = ref(1); const currentPage = ref(1);
const totalPages = ref(1); const totalPages = ref(1);
const itemsPerPage = 12; const itemsPerPage = 12;
const getCategoryIcon = (name: any) => { const { t, locale } = useI18n();
const text = getLocalizedText(name) || '' const { currentUser } = useAuth();
if (text.includes('เว็บ') || text.includes('Web') || text.includes('โปรแกรม') || text.includes('Program') || text.includes('โค้ด')) return 'code' const $q = useQuasar();
if (text.includes('ออกแบบ') || text.includes('Design') || text.includes('UI')) return 'palette' const { fetchCategories } = useCategory();
if (text.includes('ธุรกิจ') || text.includes('Business') || text.includes('การตลาด') || text.includes('Market')) return 'trending_up' const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } =
if (text.includes('ข้อมูล') || text.includes('Data') || text.includes('วิเคราะ') || text.includes('Sci')) return 'storage' useCourse();
return 'category'
// 2. Computed Properties
const sortOption = ref(t("discovery.sortRecent"));
const sortOptions = computed(() => [t("discovery.sortRecent")]);
const filteredCourses = computed(() => {
let result = courses.value;
// If more than 1 category is selected, we still do client-side filtering
// because the API currently only supports one category_id at a time.
if (selectedCategoryIds.value.length > 1) {
result = result.filter((c) =>
selectedCategoryIds.value.includes(c.category_id),
);
} }
const formatPrice = (course: any) => { if (searchQuery.value) {
if (course.is_free) return 'ฟรี'; const query = searchQuery.value.toLowerCase();
if (!course.price) return 'ฟรี'; result = result.filter((c) => {
return `฿${parseFloat(course.price).toLocaleString()}`; const title = getLocalizedText(c.title).toLowerCase();
}; const desc = getLocalizedText(c.description).toLowerCase();
return title.includes(query) || (desc && desc.includes(query));
const getInstructorName = (course: any) => { });
let user = null;
if (course.instructors && course.instructors.length > 0) {
const primary = course.instructors.find((i: any) => i.is_primary);
user = primary ? primary.user : course.instructors[0].user;
} else {
user = course.creator || course.instructor;
} }
return result;
});
if (user?.profile?.first_name) { // 3. Helper Functions
return `${user.profile.first_name} ${user.profile.last_name || ''}`.trim();
}
if (user?.first_name) {
return `${user.first_name} ${user.last_name || ''}`.trim();
}
return user?.username || 'ผู้สอน';
};
// 4. API Actions
const loadCategories = async () => { const loadCategories = async () => {
const res = await fetchCategories(); const res = await fetchCategories();
if (res.success) categories.value = res.data || []; if (res.success) categories.value = res.data || [];
@ -78,28 +76,22 @@ const loadCategories = async () => {
const loadCourses = async (page = 1) => { const loadCourses = async (page = 1) => {
isLoading.value = true; isLoading.value = true;
const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number;
// Use server-side filtering if exactly one category is selected
const categoryId =
selectedCategoryIds.value.length === 1
? selectedCategoryIds.value[0]
: undefined;
const res = await fetchCourses({ const res = await fetchCourses({
category_id: categoryId, category_id: categoryId,
search: searchQuery.value,
page: page, page: page,
limit: itemsPerPage, limit: itemsPerPage,
forceRefresh: true, forceRefresh: true,
}); });
if (res.success) { if (res.success) {
courses.value = (res.data || []).map(c => { courses.value = res.data || [];
const cat = categories.value.find(cat => cat.id === c.category_id);
return {
...c,
category_name: cat ? getLocalizedText(cat.name) : '',
instructor_name: getInstructorName(c),
formatted_price: formatPrice(c),
rating: c.rating || '4.9',
reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100
}
});
totalPages.value = res.totalPages || 1; totalPages.value = res.totalPages || 1;
currentPage.value = res.page || 1; currentPage.value = res.page || 1;
} }
@ -133,175 +125,207 @@ const handleEnroll = async (id: number) => {
isEnrolling.value = false; isEnrolling.value = false;
}; };
// Watch for category selection changes to reload courses
watch( watch(
activeCategory, selectedCategoryIds,
() => { () => {
currentPage.value = 1; currentPage.value = 1;
loadCourses(1); loadCourses(1);
} },
{ deep: true },
); );
onMounted(async () => { const toggleCategory = (id: number) => {
await loadCategories(); const index = selectedCategoryIds.value.indexOf(id);
if (index === -1) {
// Check if category_id or course_id is in query selectedCategoryIds.value.push(id);
const route = useRoute(); } else {
if (route.query.category_id) { selectedCategoryIds.value.splice(index, 1);
activeCategory.value = Number(route.query.category_id);
} }
};
await loadCourses(1); onMounted(() => {
loadCategories();
if (route.query.course_id) { loadCourses();
selectCourse(Number(route.query.course_id));
}
}); });
</script> </script>
<template> <template>
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300"> <div class="page-container">
<div class="max-w-[1240px] mx-auto"> <!-- CATALOG VIEW: Browse courses -->
<!-- วนของการคนหาคอร (Catalog View) --> <div v-if="!showDetail">
<div v-if="!showDetail" 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"> <!-- Top Header Area -->
<div class="flex flex-col gap-6 mb-10">
<!-- วนหวและการคนหา --> <div class="flex items-start gap-4 mb-4">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8"> <span
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2> class="w-1.5 h-10 md:h-12 bg-blue-600 rounded-full shadow-lg shadow-blue-500/50 mt-1 flex-shrink-0"
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto"> ></span>
<div class="relative w-full sm:w-[260px] flex-1"> <div>
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" /> <h1
<input v-model="searchQuery" @keyup.enter="loadCourses(1)" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." /> class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight"
</div> >
<div class="flex items-center gap-2 shrink-0"> {{ $t("discovery.title") }}
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button> </h1>
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button> <p
</div> v-if="filteredCourses.length > 0"
</div> class="text-slate-500 dark:text-slate-400 mt-1 font-medium"
</div> >
{{ $t("discovery.foundTotal") }}
<!-- Filters Category --> <span class="text-blue-600 font-bold leading-none">{{
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-10 w-full relative"> filteredCourses.length
<!-- Figma Style: Separate pill buttons --> }}</span>
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto"> {{ $t("discovery.items") }}
<button </p>
@click="activeCategory = 'all'"
:class="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด
</button>
<button
v-for="cat in categories" :key="cat.id"
@click="activeCategory = cat.id"
:class="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none bg-transparent">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/>
{{ getLocalizedText(cat.name) }}
</button>
</div>
</div>
<!-- Loader -->
<div v-if="isLoading" class="flex justify-center py-24">
<q-spinner size="3rem" color="primary" />
</div>
<div v-else-if="courses.length > 0">
<!-- 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">
<div v-for="course in courses" :key="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" @click="selectCourse(course.id)">
<!-- 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;">
{{ course.category_name }}
</div> </div>
</div> </div>
<!-- Body --> <!-- Unified Filter Section: Categories -->
<div class="p-5 flex flex-col flex-1"> <div
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2">{{ getLocalizedText(course.title) }}</h3> class="bg-white dark:bg-slate-900/50 p-2 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex flex-wrap items-center gap-1.5 shadow-sm"
>
<q-btn
flat
<div class="mt-auto flex items-center justify-between"> rounded
<div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'"> dense
{{ course.formatted_price }} class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
</div> :class="
<!-- Eye icon circle button --> selectedCategoryIds.length === 0
<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"> ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
<q-icon name="visibility" size="18px" /> : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
</button> "
</div> @click="selectedCategoryIds = []"
</div> :label="$t('discovery.showAll')"
/>
<q-btn
v-for="cat in categories"
:key="cat.id"
flat
rounded
dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
:class="
selectedCategoryIds.includes(cat.id)
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
"
@click="toggleCategory(cat.id)"
:label="getLocalizedText(cat.name)"
/>
</div> </div>
</div> </div>
<!-- LIST VIEW --> <!-- Main Layout: Grid Only -->
<div v-else class="flex flex-col gap-5"> <div class="w-full">
<div v-for="course in courses" :key="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" @click="selectCourse(course.id)"> <div v-if="filteredCourses.length > 0" class="flex flex-col gap-12">
<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"> <div
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" /> class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
<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.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;"> >
{{ course.category_name }} <CourseCard
</div> v-for="course in filteredCourses"
</div> :key="course.id"
<div class="flex flex-col flex-1 py-1"> v-bind="{ ...course, image: course.thumbnail_url }"
<div class="flex-1"> show-view-details
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2">{{ getLocalizedText(course.title) }}</h3> @view-details="selectCourse(course.id)"
/>
</div>
<div class="mt-4 sm:mt-auto flex items-center justify-between">
<div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }}
</div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
<q-icon name="visibility" size="16px" /> รายละเอยด
</button>
</div>
</div>
</div>
</div> </div>
<!-- Pagination --> <!-- Pagination Controls -->
<div v-if="totalPages > 1" class="flex justify-center pt-8 pb-4"> <div v-if="totalPages > 1" class="flex justify-center pb-10">
<q-pagination v-model="currentPage" :max="totalPages" :max-pages="6" boundary-numbers direction-links color="primary" flat active-design="unelevated" active-color="primary" @update:model-value="loadCourses"/> <q-pagination
v-model="currentPage"
:max="totalPages"
:max-pages="6"
boundary-numbers
direction-links
color="primary"
flat
active-design="unelevated"
active-color="primary"
@update:model-value="loadCourses"
/>
</div> </div>
</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"> <div
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" /> v-else
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3> class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 shadow-sm"
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t("discovery.emptyDesc") }}</p> >
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';"> <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">
{{ $t("discovery.emptyTitle") }}
</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
{{ $t("discovery.emptyDesc") }}
</p>
<button
class="mt-6 font-bold text-blue-600 hover:text-blue-700 dark:hover:text-blue-400 transition-colors"
@click="
searchQuery = '';
selectedCategoryIds = [];
"
>
{{ $t("discovery.showAll") }} {{ $t("discovery.showAll") }}
</button> </button>
</div> </div>
</div> </div>
</div>
<!-- COURSE DETAIL VIEW: Detailed information about a specific course --> <!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
<div v-else> <div v-else>
<button @click="showDetail = false" class="inline-flex items-center gap-2 text-slate-600 dark:text-white hover:text-blue-600 dark:hover:text-blue-300 mb-6 transition-all font-black text-lg md:text-xl group"> <button
<q-icon name="arrow_back" size="24px" class="transition-transform group-hover:-translate-x-1" /> @click="showDetail = false"
class="inline-flex items-center gap-2 text-slate-600 dark:text-white hover:text-blue-600 dark:hover:text-blue-300 mb-6 transition-all font-black text-lg md:text-xl group"
>
<q-icon
name="arrow_back"
size="24px"
class="transition-transform group-hover:-translate-x-1"
/>
{{ $t("discovery.backToCatalog") }} {{ $t("discovery.backToCatalog") }}
</button> </button>
<div v-if="isLoadingDetail" class="flex justify-center py-20"><q-spinner size="3rem" color="primary" /></div>
<CourseDetailView v-else-if="selectedCourse" :course="selectedCourse" :user="currentUser" @back="showDetail = false" @enroll="handleEnroll"/> <div v-if="isLoadingDetail" class="flex justify-center py-20">
<q-spinner size="3rem" color="primary" />
</div> </div>
<CourseDetailView
v-else-if="selectedCourse"
:course="selectedCourse"
:user="currentUser"
@back="showDetail = false"
@enroll="handleEnroll"
/>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* Disable default scrollbar for filter container */ /* Standard overrides for Quasar inputs to match Tailwind theme */
.scrollbar-hide::-webkit-scrollbar { .search-input :deep(.q-field__control) {
display: none; border-radius: 9999px; /* Full rounded pill */
background-color: white !important;
transition: all 0.3s ease;
} }
.scrollbar-hide {
-ms-overflow-style: none; .dark .search-input :deep(.q-field__control) {
scrollbar-width: none; background-color: #1e293b !important; /* slate-800: Inner card depth */
border-color: rgba(255, 255, 255, 0.1) !important;
}
.search-input :deep(.q-field__native) {
color: #0f172a !important; /* slate-900: Dark text for light mode */
}
.dark .search-input :deep(.q-field__native) {
color: white !important;
}
.search-input :deep(.q-field__shadow) {
box-shadow: none !important;
} }
</style> </style>

View file

@ -1,22 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file index.vue * @file courses.vue
* @description หนาแสดงคอรสเรยนทงหมดในรปแบบแคตตาลอกสาธารณะ * @description Page displaying all available courses in a public catalog format.
* ไซนปรบใหนสมยเพอดงดดผใชงานใหม * Matches the premium dark theme of the landing page.
*/ */
// Define page metadata using the landing layout (dark theme default)
definePageMeta({ definePageMeta({
layout: 'landing' layout: 'landing'
}) })
// Set the HTML head title for SEO
useHead({ useHead({
title: 'คอร์สทั้งหมด - E-Learning System' title: 'คอร์สทั้งหมด - E-Learning System'
}) })
// Reactive state for the search input
const searchQuery = ref('') const searchQuery = ref('')
const { fetchCourses } = useCourse() const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory() const { fetchCategories, categories } = useCategory()
// Helper to handle localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => { const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
@ -26,6 +30,7 @@ const getLocalizedText = (text: string | { th: string; en: string } | undefined)
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// State for selected category
const selectedCategory = ref((route.query.category as string) || 'all') const selectedCategory = ref((route.query.category as string) || 'all')
const selectCategory = (slug: string) => { const selectCategory = (slug: string) => {
@ -36,234 +41,345 @@ const selectCategory = (slug: string) => {
} }
} }
// Watch route query to sync state
watch(() => route.query.category, (newCategory) => { watch(() => route.query.category, (newCategory) => {
selectedCategory.value = (newCategory as string) || 'all' selectedCategory.value = (newCategory as string) || 'all'
}) })
const getCategoryIcon = (name: any) => { // Specific labels mapping as requested
const text = getLocalizedText(name) || '' const categoryLabels: Record<string, string> = {
if (text.includes('เว็บ') || text.includes('Web') || text.includes('โปรแกรม') || text.includes('Program') || text.includes('โค้ด')) return 'code' all: "ทั้งหมด",
if (text.includes('ออกแบบ') || text.includes('Design') || text.includes('UI')) return 'palette' programming: "การเขียนโปรแกรม",
if (text.includes('ธุรกิจ') || text.includes('Business') || text.includes('การตลาด') || text.includes('Market')) return 'trending_up' design: "การออกแบบ",
if (text.includes('ข้อมูล') || text.includes('Data') || text.includes('วิเคราะ') || text.includes('Sci')) return 'storage' business: "ธุรกิจ"
return 'category'
} }
const getCategoryLabel = (category: any) => {
if (categoryLabels[category.slug]) {
return categoryLabels[category.slug]
}
return getLocalizedText(category.name)
}
// Fetch categories on mount
await useAsyncData('categories-list', () => fetchCategories()) await useAsyncData('categories-list', () => fetchCategories())
const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData( // Fetch courses from API (reactive to selectedCategory)
const { data: coursesResponse, error, refresh } = await useAsyncData(
'browse-courses-list', 'browse-courses-list',
() => { () => {
const params: any = {} const params: any = {}
console.log('Fetching courses. Selected Category:', selectedCategory.value)
if (selectedCategory.value !== 'all') { if (selectedCategory.value !== 'all') {
const category = categories.value.find(c => c.slug === selectedCategory.value) const category = categories.value.find(c => c.slug === selectedCategory.value)
console.log('Found Category:', category)
if (category) { if (category) {
params.category_id = category.id params.category_id = category.id
} }
} }
console.log('Params being sent to fetchCourses:', params)
return fetchCourses(params) return fetchCourses(params)
} }
) )
watch(selectedCategory, () => { refresh() }) // Watch for category changes and refresh data
watch(selectedCategory, (newVal) => {
const formatPrice = (course: any) => { console.log('Selected Category Changed to:', newVal)
if (course.is_free) return 'ฟรี'; refresh()
if (!course.price) return 'ฟรี';
return `฿${parseFloat(course.price).toLocaleString()}`;
}
const getInstructorName = (course: any) => {
let user = null;
if (course.instructors && course.instructors.length > 0) {
const primary = course.instructors.find((i: any) => i.is_primary);
user = primary ? primary.user : course.instructors[0].user;
} else {
user = course.creator || course.instructor;
}
if (user?.profile?.first_name) {
return `${user.profile.first_name} ${user.profile.last_name || ''}`.trim();
}
if (user?.first_name) {
return `${user.first_name} ${user.last_name || ''}`.trim();
}
return user?.username || 'ผู้สอน';
}
const courses = computed(() => {
if (coursesResponse.value?.success && coursesResponse.value.data) {
return coursesResponse.value.data.map((c: any) => {
const cat = categories.value.find((cat: any) => cat.id === c.category_id);
return {
...c,
category_name: cat ? getLocalizedText(cat.name) : '',
instructor_name: getInstructorName(c),
formatted_price: formatPrice(c),
rating: c.rating || '4.9',
reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100
}
}) })
// Ref for the scroll container
const categoryScroll = ref<HTMLElement | null>(null)
// Computed property for courses list from API response
const courses = computed(() => {
if (coursesResponse.value?.success) {
return coursesResponse.value.data
} }
return [] return []
}) })
/**
* @computed filteredCourses
* @description Filters the courses list based on the search query.
* Checks both the course title and description (case-insensitive).
*/
const filteredCourses = computed(() => { const filteredCourses = computed(() => {
const list = courses.value || [] const list = courses.value || []
if (!searchQuery.value) return list if (!searchQuery.value) return list
const query = searchQuery.value.toLowerCase() const query = searchQuery.value.toLowerCase()
return list.filter((c: any) => { return list.filter(c => {
const title = getLocalizedText(c.title).toLowerCase() const title = getLocalizedText(c.title).toLowerCase()
const desc = getLocalizedText(c.description).toLowerCase() const desc = getLocalizedText(c.description).toLowerCase()
return title.includes(query) || desc.includes(query) return title.includes(query) || desc.includes(query)
}) })
}) })
const viewMode = ref<'grid' | 'list'>('grid')
</script> </script>
<template> <template>
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen pt-32 pb-20 px-4 md:px-8 transition-colors duration-300"> <!-- Main Container: Dark Theme Base -->
<div class="max-w-[1240px] mx-auto"> <div class="relative min-h-screen text-slate-600 bg-slate-50 transition-colors">
<!-- มมองแคตตาลอกแสดงคอร (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">
<!-- วนหวและการคนหา --> <!-- ==========================================
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8"> BACKGROUND EFFECTS
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2> Ambient glows matching the index.vue theme
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto"> ========================================== -->
<div class="relative w-full sm:w-[260px] flex-1"> <div class="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" /> <div class="absolute top-[-20%] right-[-10%] w-[60%] h-[60%] rounded-full bg-blue-600/10 blur-[140px] animate-pulse-slow"/>
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." /> <div class="absolute bottom-[-20%] left-[-10%] w-[60%] h-[60%] rounded-full bg-indigo-600/10 blur-[140px] animate-pulse-slow" style="animation-delay: 3s;"/>
</div>
<!-- ==========================================
HERO SECTION
Title and subtitle area
========================================== -->
<section class="relative pt-32 pb-20 px-6 overflow-hidden">
<div class="container mx-auto max-w-6xl text-center relative z-10">
<!-- Tagline Badge -->
<!-- Main Title -->
<h1 class="text-4xl md:text-6xl font-black text-slate-900 mb-6 tracking-tight py-2 slide-up leading-[1.2] overflow-visible" style="animation-delay: 0.1s;">
คอรสเรยนออนไลน<span class="text-gradient-cyan">งหมด</span>
</h1>
<!-- Subtitle -->
<p class="text-slate-400 text-xl max-w-2xl mx-auto leading-relaxed slide-up" style="animation-delay: 0.2s;">
เรมตนอปสกลของคณวนนวยหลกสตรคณภาพทออกแบบโดยผเชยวชาญในอตสาหกรรม
</p>
</div>
</section>
<!-- ==========================================
SEARCH & GRID SECTION
========================================== -->
<!-- ==========================================
SEARCH & GRID SECTION
========================================== -->
<section class="container mx-auto max-w-[1440px] px-6 pb-20">
<!-- Content Frame Container -->
<div class="glass-premium rounded-[3rem] p-8 md:p-12 shadow-xl shadow-blue-900/5">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-8 mb-8">
<h2 class="text-2xl font-black text-slate-900 flex items-center gap-3">
<span class="w-2 h-8 bg-blue-600 rounded-full"/>
รายการคอรสเรยน
</h2>
<!-- Search Bar (Compact) -->
<div class="relative max-w-md w-full">
<div class="relative group">
<input
v-model="searchQuery"
type="text"
class="w-full pl-12 pr-6 py-3 bg-slate-100 border border-slate-200 rounded-xl text-slate-900 placeholder-slate-400 focus:outline-none focus:bg-white focus: focus:ring-2 focus:ring-blue-500/50 transition-all font-medium"
placeholder="ค้นหาบทเรียน..."
>
<div class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div> </div>
<div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
</div> </div>
</div> </div>
</div> </div>
<!-- วกรองหมวดหม (Filters Category) --> <!-- Category Filter Tabs with Scroll Buttons -->
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-10 w-full relative"> <div class="relative mb-8">
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto"> <!-- Left Scroll Button -->
<button <button
class="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white shadow-md border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all md:hidden"
@click="categoryScroll?.scrollBy({ left: -200, behavior: 'smooth' })"
>
<q-icon name="chevron_left" size="24px" />
</button>
<!-- Scrollable Container -->
<div
ref="categoryScroll"
class="flex items-center gap-3 overflow-x-auto pb-2 no-scrollbar px-1 scroll-smooth"
>
<button
class="px-6 py-2.5 rounded-full font-bold text-sm transition-all whitespace-nowrap border-2 flex-shrink-0"
:class="selectedCategory === 'all' ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
@click="selectCategory('all')" @click="selectCategory('all')"
:class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'" >
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none"> {{ categoryLabels.all }}
<q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด
</button> </button>
<button <button
v-for="cat in categories" :key="cat.id" v-for="category in categories"
@click="selectCategory(cat.slug)" :key="category.id"
:class="selectedCategory === cat.slug ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'" class="px-6 py-2.5 rounded-full font-bold text-sm transition-all whitespace-nowrap border-2 flex-shrink-0"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none bg-transparent"> :class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="selectedCategory === cat.slug ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/> @click="selectCategory(category.slug)"
{{ getLocalizedText(cat.name) }} >
{{ getCategoryLabel(category) }}
</button> </button>
</div> </div>
</div>
<!-- วแสดงการโหลด (Loader) --> <!-- Right Scroll Button -->
<div v-if="isLoading" class="flex justify-center py-24"> <button
<q-spinner size="3rem" color="primary" /> class="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white shadow-md border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all md:hidden"
</div> @click="categoryScroll?.scrollBy({ left: 200, behavior: 'smooth' })"
>
<div v-else-if="filteredCourses.length > 0"> <q-icon name="chevron_right" size="24px" />
<!-- มมองแบบกร (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) -->
<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;">
{{ course.category_name }}
</div>
</div>
<!-- เนอหาคอร (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>
<div class="flex items-center gap-2 mb-4">
<span class="text-[12px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
</div>
<div class="flex items-center gap-1.5 mb-5">
<q-icon name="star" class="text-amber-400" size="16px" />
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span>
</div>
<div class="mt-auto flex items-center justify-between">
<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) -->
<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> </button>
</div> </div>
<!-- Course Grid (Updated to 4 cols) -->
<div v-if="filteredCourses.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div
v-for="(course, index) in filteredCourses"
:key="course.id"
class="glass-card group flex flex-col h-full hover:-translate-y-2 transition-transform duration-500 slide-up"
:style="{ animationDelay: `${0.1 * (index + 1)}s` }"
>
<!-- Card Image -->
<div class="h-48 bg-gradient-to-br from-slate-800 to-slate-900 relative overflow-hidden group-hover:opacity-90 transition-opacity">
<img
v-if="course.thumbnail_url"
:src="course.thumbnail_url"
:alt="getLocalizedText(course.title)"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div
v-else
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-slate-800 to-slate-900"
>
</div> </div>
</NuxtLink>
</div> </div>
<!-- มมองแบบรายการ (LIST VIEW) --> <!-- Card Content Body -->
<div v-else class="flex flex-col gap-5"> <div class="p-6 flex-1 flex flex-col border-t border-slate-100 ">
<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"> <h3 class="text-xl font-bold text-slate-900 mb-2 leading-snug group-hover:text-blue-600 transition-colors line-clamp-2">
<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"> {{ getLocalizedText(course.title) }}
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" /> </h3>
<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.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;"> <p class="text-slate-500 text-xs mb-6 line-clamp-2 leading-relaxed flex-1">
{{ course.category_name }} {{ getLocalizedText(course.description) }}
</div> </p>
</div>
<div class="flex flex-col flex-1 py-1"> <!-- Card Footer -->
<div class="flex-1"> <div class="pt-4 border-t border-slate-100 flex items-center justify-between mt-auto">
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3> <span class="text-lg font-black text-blue-600 tracking-tight">
<div class="flex items-center gap-2 mb-3"> {{ course.is_free ? 'ฟรี' : course.price }}
<span class="text-[13px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span> </span>
</div> <NuxtLink
<div class="flex items-center gap-1.5 mb-2"> :to="`/course/${course.id}`"
<q-icon name="star" class="text-amber-400" size="16px" /> class="px-4 py-2 bg-slate-50 hover:bg-blue-600 text-slate-600 hover:text-white rounded-lg text-xs font-bold transition-all border border-slate-200 hover:border-blue-500/50"
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span> >
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span> รายละเอยด
</div>
</div>
<div class="mt-4 sm:mt-auto flex items-center justify-between">
<div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }}
</div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
<q-icon name="visibility" size="16px" /> รายละเอยด
</button>
</div>
</div>
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
</div>
</div>
<!-- กรณไมพบขอมลคอร (Empty State) --> <!-- Empty State (No Results) -->
<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"> <div v-else class="text-center py-20">
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" /> <div class="text-6xl mb-6 opacity-50 animate-bounce">🔭</div>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ searchQuery ? 'ไม่พบคอร์สที่คุณค้นหา' : 'ไม่มีคอร์สในหมวดหมู่นี้' }}</h3> <h2 class="text-2xl font-black text-slate-900 mb-3">ไมพบคอรสทณคนหา</h2>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">ลองใชคำคนหาอ หรอเลอกหมวดหมนเพอดคอรสทเรามใหบรการ</p> <p class="text-slate-400 mb-8 max-w-md mx-auto">
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';"> ลองใชคำคนหาอ หรอดคอรสทงหมดทเรามใหบรการ
</p>
<button
class="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition-all shadow-lg shadow-blue-600/20"
@click="searchQuery = ''"
>
แสดงคอรสทงหมด แสดงคอรสทงหมด
</button> </button>
</div> </div>
</div> </div>
</section>
<!-- ==========================================
CTA SECTION
Call to action to register
========================================== -->
<section class="py-24 relative overflow-hidden">
<!-- Background Decoration -->
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-blue-50/80 pointer-events-none -z-10"/>
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-blue-400/10 blur-[120px] rounded-full -z-10 pointer-events-none"/>
<div class="container mx-auto max-w-4xl text-center relative z-10 px-6">
<h2 class="text-4xl md:text-5xl font-black text-slate-900 mb-6 tracking-tight">
พรอมจะเรมตนแลวหรอย?
</h2>
<p class="text-slate-500 text-lg md:text-xl mb-10 max-w-2xl mx-auto leading-relaxed">
ลงทะเบยนฟรนนเพอเขาถงบทเรยนพนฐาน และตดตามความคบหนาการเรยนของคณไดนท ไมาใชายแอบแฝง
</p>
<NuxtLink
to="/auth/register"
class="inline-flex items-center gap-3 px-10 py-5 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white rounded-2xl text-lg font-black shadow-2xl shadow-blue-900/40 hover:scale-105 transition-all duration-300"
>
<span></span>
<span>สมครสมาชกฟร</span>
</NuxtLink>
</div> </div>
</section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* ปิดการแสดงแถบเลื่อนบนคอนเทนเนอร์ของตัวกรอง (Disable default scrollbar for filter container) */ /*
.no-scrollbar::-webkit-scrollbar { MATCHING INDEX.VUE STYLES
display: none; These styles ensure consistency with the landing page theme.
*/
/* Gradient Text Effect (Cyan to Blue) */
.text-gradient-cyan {
background: linear-gradient(135deg, #22d3ee 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: inline-block;
padding: 0.5em 0.2em;
margin: -0.5em -0.2em;
vertical-align: baseline;
} }
.no-scrollbar {
-ms-overflow-style: none; /* Premium Glass Effect for Containers */
scrollbar-width: none; .glass-premium {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
/* Glass Card Style for Course Items */
.glass-card {
background: white;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 2rem;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
}
/* Slow Pulse Animation for Background Glows */
@keyframes pulse-slow {
0%, 100% { opacity: 0.1; transform: scale(1); }
50% { opacity: 0.15; transform: scale(1.15); }
}
.animate-pulse-slow {
animation: pulse-slow 10s linear infinite;
}
/* Slide Up Entrance Animation */
@keyframes slide-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.slide-up {
animation: slide-up 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
opacity: 0;
} }
</style> </style>

View file

@ -113,7 +113,7 @@ const filteredCourses = computed(() => {
<!-- Tagline Badge --> <!-- Tagline Badge -->
<!-- Main Title --> <!-- Main Title -->
<h1 class="text-4xl md:text-6xl font-black text-slate-900 mb-6 tracking-tight py-2 slide-up leading-[1.2] overflow-visible" style="animation-delay: 0.1s;"> <h1 class="text-4xl md:text-6xl font-black text-slate-900 mb-6 tracking-tight py-2 slide-up leading-[1.2] overflow-visible" style="animation-delay: 0.1s;">
คอรสเรยน<span class="text-gradient-cyan">แนะนำ</span> คอรสเรยนออนไลน<span class="text-gradient-cyan">แนะนำ</span>
</h1> </h1>
<!-- Subtitle --> <!-- Subtitle -->
<p class="text-slate-400 text-xl max-w-2xl mx-auto leading-relaxed slide-up" style="animation-delay: 0.2s;"> <p class="text-slate-400 text-xl max-w-2xl mx-auto leading-relaxed slide-up" style="animation-delay: 0.2s;">
@ -130,37 +130,27 @@ const filteredCourses = computed(() => {
<!-- Content Frame Container --> <!-- Content Frame Container -->
<div class="glass-premium rounded-[3rem] p-8 md:p-12 shadow-xl shadow-blue-900/5"> <div class="glass-premium rounded-[3rem] p-8 md:p-12 shadow-xl shadow-blue-900/5">
<!-- New Enhanced Search Section (Image 2 Style) --> <div class="flex flex-col md:flex-row md:items-center justify-between gap-8 mb-8">
<div class="bg-blue-50/50 rounded-[2.5rem] p-8 md:p-10 mb-6 border border-blue-100/50"> <h2 class="text-2xl font-black text-slate-900 flex items-center gap-3">
<h2 class="text-2xl md:text-3xl font-black text-slate-900 mb-2">คอรสเรยนแนะนำ</h2> <span class="w-2 h-8 bg-blue-600 rounded-full"/>
<p class="text-slate-500 font-medium mb-8">ดสรรเนอหาคณภาพสงทณไมควรพลาด</p> คอรสทณหามพลาด
</h2>
<div class="flex flex-col md:flex-row gap-4"> <!-- Search Bar (Compact) -->
<!-- Search Input --> <div class="relative max-w-md w-full">
<div class="relative flex-1 group"> <div class="relative group">
<div class="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-600 transition-colors">
<q-icon name="search" size="24px" />
</div>
<input <input
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
placeholder="ค้นหาชื่อคอร์สแนะนำ..." class="w-full pl-12 pr-6 py-3 bg-slate-100 border border-slate-200 rounded-xl text-slate-900 placeholder-slate-400 focus:outline-none focus:bg-white focus: focus:ring-2 focus:ring-blue-500/50 transition-all font-medium"
class="w-full pl-14 pr-6 py-4 bg-white border-2 border-transparent rounded-2xl text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blue-500/20 focus:ring-4 focus:ring-blue-500/5 transition-all text-lg font-medium shadow-sm" placeholder="ค้นหาคอร์สแนะนำ..."
/>
</div>
<!-- Search Button -->
<q-btn
unelevated
color="primary"
class="px-4 h-16 rounded-2xl font-black shadow-lg shadow-blue-600/20 hover:scale-[1.02] transition-transform"
no-caps
> >
<div class="flex items-center gap-2"> <div class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
<q-icon name="search" size="20px" /> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<span class="text-base">นหา</span> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div> </div>
</q-btn>
</div> </div>
</div> </div>

View file

@ -22,7 +22,7 @@ const { user } = useAuth()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements, markLessonComplete, getLocalizedText } = useCourse() const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements, markLessonComplete, getLocalizedText } = useCourse()
const $q = useQuasar() const $q = useQuasar()
// (State management) // State management
const sidebarOpen = ref(false) const sidebarOpen = ref(false)
const courseId = computed(() => Number(route.query.course_id)) const courseId = computed(() => Number(route.query.course_id))
@ -31,11 +31,11 @@ const courseId = computed(() => Number(route.query.course_id))
// ========================================== // ==========================================
// courseData: () // courseData: ()
const courseData = ref<any>(null) const courseData = ref<any>(null)
const announcements = ref<any[]>([]) // (Announcements state) const announcements = ref<any[]>([]) // Announcements state
const showAnnouncementsModal = ref(false) // (Modal state) const showAnnouncementsModal = ref(false) // Modal state
const hasUnreadAnnouncements = ref(false) // (Unread state tracking) const hasUnreadAnnouncements = ref(false) // Unread state tracking
// (Helper for persistent read status) // Helper for persistent read status
const getAnnouncementStorageKey = () => { const getAnnouncementStorageKey = () => {
if (!user.value?.id || !courseId.value) return '' if (!user.value?.id || !courseId.value) return ''
return `read_announcements:${user.value.id}:${courseId.value}` return `read_announcements:${user.value.id}:${courseId.value}`
@ -61,17 +61,17 @@ const checkUnreadAnnouncements = () => {
const lastReadDate = new Date(lastRead).getTime() const lastReadDate = new Date(lastRead).getTime()
const hasNew = announcements.value.some(a => { const hasNew = announcements.value.some(a => {
const annDate = new Date(a.created_at || Date.now()).getTime() 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 return annDate > lastReadDate
}) })
hasUnreadAnnouncements.value = hasNew hasUnreadAnnouncements.value = hasNew
} }
// (Handler for opening announcements) // Handler for opening announcements
const handleOpenAnnouncements = () => { const handleOpenAnnouncements = () => {
showAnnouncementsModal.value = true showAnnouncementsModal.value = true
hasUnreadAnnouncements.value = false // (Clear unread badge on click) hasUnreadAnnouncements.value = false // Clear unread badge on click
const key = getAnnouncementStorageKey() const key = getAnnouncementStorageKey()
if (key) { if (key) {
@ -98,7 +98,7 @@ const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value sidebarOpen.value = !sidebarOpen.value
} }
// (Logic Quiz Attempt Management) // Logic Quiz Attempt Management
const quizStatus = computed(() => { const quizStatus = computed(() => {
if (!currentLesson.value || currentLesson.value.type !== 'QUIZ' || !currentLesson.value.quiz) return null 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 latestAttempt = quiz.latest_attempt
const allowMultiple = quiz.allow_multiple_attempts const allowMultiple = quiz.allow_multiple_attempts
// (If never attempted) // If never attempted
if (!latestAttempt) { if (!latestAttempt) {
return { return {
canStart: true, canStart: true,
@ -116,7 +116,7 @@ const quizStatus = computed(() => {
} }
} }
// (If multiple attempts allowed) // If multiple attempts allowed
if (allowMultiple) { if (allowMultiple) {
return { return {
canStart: true, canStart: true,
@ -128,8 +128,8 @@ const quizStatus = computed(() => {
} }
} }
// (allowMultiple is false (Single attempt only)) // allowMultiple is false (Single attempt only)
// (Lock the quiz regardless of pass/fail once attempted) // Lock the quiz regardless of pass/fail once attempted
return { return {
canStart: false, canStart: false,
label: latestAttempt.is_passed ? t('quiz.passedStatus') : t('quiz.failedStatus'), label: latestAttempt.is_passed ? t('quiz.passedStatus') : t('quiz.failedStatus'),
@ -145,7 +145,7 @@ const handleStartQuiz = () => {
const quiz = currentLesson.value.quiz 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) { if (!quiz.allow_multiple_attempts && !quiz.latest_attempt) {
$q.dialog({ $q.dialog({
title: `<div class="text-slate-900 dark:text-white font-black text-xl">${t('quiz.warningTitle')}</div>`, 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. localStorage (Clear all localStorage) // 2. Clear all localStorage
localStorage.clear() localStorage.clear()
// 3. (Restore ONLY whitelisted keys) // 3. Restore ONLY whitelisted keys
Object.keys(whitelist).forEach(key => { Object.keys(whitelist).forEach(key => {
localStorage.setItem(key, whitelist[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 window.location.href = path
} else { } else {
// SSR (SSR Fallback) // SSR Fallback
router.push(path) router.push(path)
} }
} }
@ -213,13 +213,13 @@ const resetAndNavigate = (path: string) => {
const handleLessonSelect = (lessonId: number) => { const handleLessonSelect = (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return if (currentLesson.value?.id === lessonId) return
// 1. URL (Update URL query params) // 1. Update URL query params
router.push({ query: { ...route.query, lesson_id: lessonId.toString() } }) router.push({ query: { ...route.query, lesson_id: lessonId.toString() } })
// 2. (Load content without refresh) // 2. Load content without refresh
loadLesson(lessonId) loadLesson(lessonId)
// (Close sidebar on mobile) // Close sidebar on mobile
if (sidebarOpen.value) { if (sidebarOpen.value) {
sidebarOpen.value = false sidebarOpen.value = false
} }
@ -245,7 +245,7 @@ const loadCourseData = async () => {
if (res.success) { if (res.success) {
courseData.value = res.data courseData.value = res.data
// : URL (Auto-load logic: Check URL first, fallback to first available lesson) // Auto-load logic: Check URL first, fallback to first available lesson
const urlLessonId = route.query.lesson_id ? Number(route.query.lesson_id) : null const urlLessonId = route.query.lesson_id ? Number(route.query.lesson_id) : null
if (urlLessonId) { if (urlLessonId) {
@ -258,7 +258,7 @@ const loadCourseData = async () => {
} }
} }
// (Fetch Course Announcements) // Fetch Course Announcements
const annRes = await fetchCourseAnnouncements(courseId.value) const annRes = await fetchCourseAnnouncements(courseId.value)
if (annRes.success) { if (annRes.success) {
announcements.value = annRes.data || [] announcements.value = annRes.data || []
@ -275,7 +275,7 @@ const loadCourseData = async () => {
const loadLesson = async (lessonId: number) => { const loadLesson = async (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return 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 isPlaying.value = false
videoProgress.value = 0 videoProgress.value = 0
currentTime.value = 0 currentTime.value = 0
@ -285,16 +285,16 @@ const loadLesson = async (lessonId: number) => {
lastSavedTimestamp.value = 0 lastSavedTimestamp.value = 0
lastLocalSaveTimestamp.value = 0 lastLocalSaveTimestamp.value = 0
currentDuration.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 isLessonLoading.value = true
try { try {
// : (Optional: Check access first) // Optional: Check access first
const accessRes = await checkLessonAccess(courseId.value, lessonId) const accessRes = await checkLessonAccess(courseId.value, lessonId)
if (accessRes.success && !accessRes.data.is_accessible) { if (accessRes.success && !accessRes.data.is_accessible) {
let msg = t('classroom.notAvailable') let msg = t('classroom.notAvailable')
// (Handle specific lock reasons) // Handle specific lock reasons
if (accessRes.data.lock_reason) { if (accessRes.data.lock_reason) {
msg = accessRes.data.lock_reason msg = accessRes.data.lock_reason
} else if (accessRes.data.required_quiz_pass && !accessRes.data.required_quiz_pass.is_passed) { } else if (accessRes.data.required_quiz_pass && !accessRes.data.required_quiz_pass.is_passed) {
@ -314,32 +314,32 @@ const loadLesson = async (lessonId: number) => {
return return
} }
// 1. (Fetch content) // 1. Fetch content
const res = await fetchLessonContent(courseId.value, lessonId) const res = await fetchLessonContent(courseId.value, lessonId)
if (res.success) { if (res.success) {
currentLesson.value = res.data currentLesson.value = res.data
// () (Initialize progress object if missing) // Initialize progress object if missing (Critical for New Users)
if (!currentLesson.value.progress) { if (!currentLesson.value.progress) {
currentLesson.value.progress = {} currentLesson.value.progress = {}
} }
// UI (Update Lesson Completion UI status safely) // Update Lesson Completion UI status safely
if (currentLesson.value?.progress?.is_completed && courseData.value) { if (currentLesson.value?.progress?.is_completed && courseData.value) {
for (const chapter of courseData.value.chapters) { for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === lessonId) const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
if (lesson) { if (lesson) {
if (!lesson.progress) lesson.progress = {} if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true lesson.progress.is_completed = true
lesson.is_completed = true // (Standardize completion property) lesson.is_completed = true // Standardize completion property
break break
} }
} }
} }
// 2. (Fetch Initial Progress (Resume Playback)) // 2. Fetch Initial Progress (Resume Playback)
if (currentLesson.value.type === 'VIDEO') { 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 const isCompleted = currentLesson.value.progress?.is_completed || false
if (isCompleted) { if (isCompleted) {
@ -351,7 +351,7 @@ const loadLesson = async (lessonId: number) => {
maxWatchedTime.value = 0 maxWatchedTime.value = 0
currentTime.value = 0 currentTime.value = 0
} else { } else {
// ? (Not completed? Resume from where we left off) // Not completed? Resume from where we left off
const progressRes = await fetchVideoProgress(lessonId) const progressRes = await fetchVideoProgress(lessonId)
let serverProgress = 0 let serverProgress = 0
if (progressRes.success && progressRes.data?.video_progress_seconds) { 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) const videoPlayerComp = ref(null)
// (Video & Progress State) // Video & Progress State
const initialSeekTime = ref(0) 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 lastSavedTime = ref(-1)
const lastSavedTimestamp = ref(0) // (Server throttle timestamp) const lastSavedTimestamp = ref(0) // Server throttle timestamp
const lastLocalSaveTimestamp = ref(0) // (Local throttle timestamp) const lastLocalSaveTimestamp = ref(0) // Local throttle timestamp
const currentDuration = ref(0) // (Track duration for save logic) const currentDuration = ref(0) // Track duration for save logic
// : Local Storage (Helper: Get Local Storage Key) // Helper: Get Local Storage Key
const getLocalProgressKey = (lessonId: number) => { const getLocalProgressKey = (lessonId: number) => {
if (!user.value?.id) return null if (!user.value?.id) return null
return `progress:${user.value.id}:${lessonId}` return `progress:${user.value.id}:${lessonId}`
} }
// : (Helper: Get Local Progress) // Helper: Get Local Progress
const getLocalProgress = (lessonId: number): number => { const getLocalProgress = (lessonId: number): number => {
try { try {
const key = getLocalProgressKey(lessonId) 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) => { const saveLocalProgress = (lessonId: number, time: number) => {
try { try {
const key = getLocalProgressKey(lessonId) const key = getLocalProgressKey(lessonId)
@ -416,31 +416,31 @@ const saveLocalProgress = (lessonId: number, time: number) => {
localStorage.setItem(key, time.toString()) localStorage.setItem(key, time.toString())
} }
} catch (e) { } catch (e) {
// (Ignore storage errors) // Ignore storage errors
} }
} }
// ( Component) (Handler: Video Time Update (from Component)) // Handler: Video Time Update (from Component)
const handleVideoTimeUpdate = (cTime: number, dur: number) => { const handleVideoTimeUpdate = (cTime: number, dur: number) => {
currentDuration.value = dur || 0 currentDuration.value = dur || 0
// (Update Monotonic Progress) // Update Monotonic Progress
if (cTime > maxWatchedTime.value) { if (cTime > maxWatchedTime.value) {
maxWatchedTime.value = cTime maxWatchedTime.value = cTime
} }
// : (Logic: Periodic Save) // Logic: Periodic Save
if (currentLesson.value?.id) { if (currentLesson.value?.id) {
const now = Date.now() const now = Date.now()
// 1. (5 ) (Local Save Throttle (5 seconds)) // 1. Local Save Throttle (5 seconds)
if (now - lastLocalSaveTimestamp.value > 5000) { if (now - lastLocalSaveTimestamp.value > 5000) {
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value) saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
lastLocalSaveTimestamp.value = now lastLocalSaveTimestamp.value = now
} }
// 2. ( performSaveProgress) // 2. Server Save Throttle (handled inside performSaveProgress)
// : isPlaying (Note: We don't check isPlaying here because if time is updating, it IS playing.) // Note: We don't check isPlaying here because if time is updating, it IS playing.
performSaveProgress(false, false) 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 performSaveProgress = async (force: boolean = false, keepalive: boolean = false) => {
const lesson = currentLesson.value const lesson = currentLesson.value
if (!lesson || lesson.type !== 'VIDEO') return if (!lesson || lesson.type !== 'VIDEO') return
// (Ensure progress object exists) // Ensure progress object exists
if (!lesson.progress) lesson.progress = {} 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 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 if (isCompleting.value) return
const now = Date.now() 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) const durationSec = Math.floor(currentDuration.value || 0)
// 3. : 0 (lastSavedTime is -1) (Monotonic Check: Allow saving 0 if it's the very first save) // 3. Monotonic Check: Allow saving 0 if it's the very first save (lastSavedTime is -1)
if (!force) { if (!force) {
if (lastSavedTime.value === -1) { if (lastSavedTime.value === -1) {
// : 0 (First time save: allow 0 or more) // First time save: allow 0 or more
if (maxSec < 0) return if (maxSec < 0) return
} else if (maxSec <= lastSavedTime.value) { } else if (maxSec <= lastSavedTime.value) {
// : (Subsequent saves: must be greater than last saved) // Subsequent saves: must be greater than last saved
return return
} }
} }
// 4. : (15 ) (Throttle Check: Server Throttle (15 seconds)) // 4. Throttle Check: Server Throttle (15 seconds)
if (!force && (now - lastSavedTimestamp.value < 15000)) return if (!force && (now - lastSavedTimestamp.value < 15000)) return
// (Prepare for Save) // Prepare for Save
lastSavedTime.value = maxSec lastSavedTime.value = maxSec
lastSavedTimestamp.value = now lastSavedTimestamp.value = now
// ( 100% ) (Check if this save might complete the lesson) // Check if this save might complete the lesson (e.g. 100% or forced end)
const isFinishing = force || (durationSec > 0 && maxSec >= durationSec) const isFinishing = force || (durationSec > 0 && maxSec >= durationSec)
if (isFinishing) { if (isFinishing) {
@ -503,8 +503,8 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
try { try {
const res = await saveVideoProgress(lesson.id, maxSec, durationSec, keepalive) const res = await saveVideoProgress(lesson.id, maxSec, durationSec, keepalive)
// (: 95%) (Handle Completion (Frontend-only strategy: 95% threshold)) // Handle Completion (Frontend-only strategy: 95% threshold)
// 95% (This ensures the checkmark appears at 95% to match backend.) // This ensures the checkmark appears at 95% to match backend.
const progressPercentage = durationSec > 0 ? (maxSec / durationSec) : 0 const progressPercentage = durationSec > 0 ? (maxSec / durationSec) : 0
const isCompletedNow = res.success && (res.data?.is_completed || progressPercentage >= 0.95) 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) markLessonAsCompletedLocally(lesson.id)
if (lesson.progress) lesson.progress.is_completed = true 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) { if (!wasAlreadyCompleted) {
await loadCourseData() 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) => { const markLessonAsCompletedLocally = (lessonId: number) => {
if (courseData.value) { if (courseData.value) {
for (const chapter of courseData.value.chapters) { for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === lessonId) const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
if (lesson) { if (lesson) {
// API (Compatible with API structure) // Compatible with API structure
lesson.is_completed = true lesson.is_completed = true
if (!lesson.progress) lesson.progress = {} if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true lesson.progress.is_completed = true
@ -547,11 +547,11 @@ const videoSrc = computed(() => {
if (!currentLesson.value) return '' if (!currentLesson.value) return ''
let url = '' let url = ''
// video_url API (Use explicit video_url from API first) // Use explicit video_url from API first
if (currentLesson.value.video_url) { if (currentLesson.value.video_url) {
url = currentLesson.value.video_url url = currentLesson.value.video_url
} else { } else {
// () // Fallback (deprecated logic)
const content = getLocalizedText(currentLesson.value.content) const content = getLocalizedText(currentLesson.value.content)
if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) { if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) {
url = content url = content
@ -560,7 +560,7 @@ const videoSrc = computed(() => {
if (!url) return '' if (!url) return ''
// YouTube (Support Resume for YouTube) // Support Resume for YouTube
const isYoutube = url.toLowerCase().includes('youtube.com') || url.toLowerCase().includes('youtu.be') const isYoutube = url.toLowerCase().includes('youtube.com') || url.toLowerCase().includes('youtu.be')
if (isYoutube && initialSeekTime.value > 0) { if (isYoutube && initialSeekTime.value > 0) {
const separator = url.includes('?') ? '&' : '?' const separator = url.includes('?') ? '&' : '?'
@ -575,7 +575,7 @@ const onVideoEnded = async () => {
const lesson = currentLesson.value const lesson = currentLesson.value
if (!lesson) return if (!lesson) return
// localStorage (Clear local storage on end since it's completed) // Clear local storage on end since it's completed
const key = getLocalProgressKey(lesson.id) const key = getLocalProgressKey(lesson.id)
if (key && typeof window !== 'undefined') { if (key && typeof window !== 'undefined') {
localStorage.removeItem(key) localStorage.removeItem(key)
@ -598,7 +598,7 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { 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 courseData.value = null
currentLesson.value = null currentLesson.value = null
}) })
@ -665,7 +665,7 @@ onBeforeUnmount(() => {
</q-toolbar> </q-toolbar>
</q-header> </q-header>
<!-- แถบดานขาง (บทเรยน) - วางชดขวาผานพรอพพ --> <!-- Sidebar (Curriculum) - Positioned Right via component prop -->
<CurriculumSidebar <CurriculumSidebar
v-model="sidebarOpen" v-model="sidebarOpen"
:courseData="courseData" :courseData="courseData"
@ -676,14 +676,14 @@ onBeforeUnmount(() => {
@open-announcements="handleOpenAnnouncements" @open-announcements="handleOpenAnnouncements"
/> />
<!-- นทเนอหาหล (Main Content) --> <!-- Main Content -->
<q-page-container class="bg-white dark:bg-slate-900"> <q-page-container class="bg-white dark:bg-slate-900">
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]"> <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"> <div class="w-full h-full p-4 md:p-6 flex-grow overflow-y-auto">
<!-- 1. สถานะกำลงโหลด (โครงสรางเสมอน (Skeleton) สมบรณแบบ) (LOADING STATE (Comprehensive Skeleton)) --> <!-- 1. LOADING STATE (Comprehensive Skeleton) -->
<div v-if="isLessonLoading" class="animate-fade-in"> <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"> <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 <img
v-if="courseData?.course?.thumbnail_url" v-if="courseData?.course?.thumbnail_url"
@ -697,7 +697,7 @@ onBeforeUnmount(() => {
</div> </div>
</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="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-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> <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>
</div> </div>
<!-- 2. สถานะพรอมใชงาน (อมลบทเรยนจร) (READY STATE (Real Lesson Content)) --> <!-- 2. READY STATE (Real Lesson Content) -->
<div v-else-if="currentLesson" class="animate-fade-in"> <div v-else-if="currentLesson" class="animate-fade-in">
<!-- วนการเลนวโอ (Video Player) --> <!-- Video Player -->
<VideoPlayer <VideoPlayer
v-if="videoSrc" v-if="videoSrc"
ref="videoPlayerComp" ref="videoPlayerComp"
@ -719,7 +719,7 @@ onBeforeUnmount(() => {
@loadedmetadata="(d: number) => onVideoMetadataLoaded(d)" @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="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"> <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> <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>
<!-- องบทเรยน (Text/HTML) (Lesson Content Area) --> <!-- Lesson Content Area (Text/HTML) -->
<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 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"> <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" /> <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 v-html="getLocalizedText(currentLesson.content)" class="leading-relaxed text-slate-800 dark:text-slate-200"></div>
</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"> <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"> <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"> <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"> <script setup lang="ts">
/** /**
* @file quiz.vue * @file quiz.vue
* @description หนาสำหรบทำแบบทดสอบ (Quiz Interface) * @description Quiz Interface.
* ดการวงจรชตของการทำแบบทดสอบทงหมด: เรมต -> ทำขอสอบ -> ผลลพธ -> ทบทวน * Manages the entire quiz lifecycle: Start -> Taking -> Results -> Review.
* เจอรบเวลา การนำทางระหวางคำถาม และการวเคราะหผลลพธอยางละเอยด * Features a timer, question navigation, and detailed result analysis.
*/ */
definePageMeta({ definePageMeta({
@ -17,7 +17,7 @@ const router = useRouter()
const $q = useQuasar() const $q = useQuasar()
const { fetchCourseLearningInfo, fetchLessonContent, submitQuiz: apiSubmitQuiz, markLessonComplete } = useCourse() const { fetchCourseLearningInfo, fetchLessonContent, submitQuiz: apiSubmitQuiz, markLessonComplete } = useCourse()
// (State Management) // State Management
const currentScreen = ref<'start' | 'taking' | 'result' | 'review'>('start') const currentScreen = ref<'start' | 'taking' | 'result' | 'review'>('start')
const timeLeft = ref(0) const timeLeft = ref(0)
let timerInterval: ReturnType<typeof setInterval> | null = null let timerInterval: ReturnType<typeof setInterval> | null = null
@ -30,20 +30,20 @@ const quizData = ref<any>(null)
const isLoading = ref(true) const isLoading = ref(true)
const isSubmitting = ref(false) const isSubmitting = ref(false)
// (Quiz Taking State) // Quiz Taking State
const currentQuestionIndex = ref(0) const currentQuestionIndex = ref(0)
const userAnswers = ref<Record<number, number>>({}) // ID -> ID (questionId -> choiceId) const userAnswers = ref<Record<number, number>>({}) // questionId -> choiceId
const visitedQuestions = ref<Set<number>>(new Set()) // (Track visited indices) const visitedQuestions = ref<Set<number>>(new Set()) // Track visited indices
const quizResult = ref<any>(null) const quizResult = ref<any>(null)
// (Tracking visited questions) // Tracking visited questions
watch(currentQuestionIndex, (newVal) => { watch(currentQuestionIndex, (newVal) => {
visitedQuestions.value.add(newVal) visitedQuestions.value.add(newVal)
}, { immediate: true }) }, { immediate: true })
// : (Helper: Get Status Color Class) // Helper: Get Status Color Class
const getQuestionStatusClass = (index: number, questionId: number) => { const getQuestionStatusClass = (index: number, questionId: number) => {
// 1. = (Current = Blue) // 1. Current = Blue
if (index === currentQuestionIndex.value) { if (index === currentQuestionIndex.value) {
return 'bg-blue-500 text-white border-blue-600 ring-2 ring-blue-200 dark:ring-blue-900' return 'bg-blue-500 text-white border-blue-600 ring-2 ring-blue-200 dark:ring-blue-900'
} }
@ -51,29 +51,32 @@ const getQuestionStatusClass = (index: number, questionId: number) => {
const hasAnswer = userAnswers.value[questionId] !== undefined const hasAnswer = userAnswers.value[questionId] !== undefined
const isVisited = visitedQuestions.value.has(index) const isVisited = visitedQuestions.value.has(index)
// 2. = (Completed = Green) // 2. Completed = Green
if (hasAnswer) { if (hasAnswer) {
return 'bg-emerald-500 text-white border-emerald-600' return 'bg-emerald-500 text-white border-emerald-600'
} }
// 3. = () (Skipped = Orange (Visited but no answer)) // 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.
if (isVisited && !hasAnswer) { if (isVisited && !hasAnswer) {
return 'bg-orange-500 text-white border-orange-600' 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' 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) => { const jumpToQuestion = (targetIndex: number) => {
if (targetIndex === currentQuestionIndex.value) return if (targetIndex === currentQuestionIndex.value) return
// (Validation before leaving current) // Validation before leaving current (same logic as Next)
if (targetIndex > currentQuestionIndex.value) { 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 isAnswered = userAnswers.value[currentQuestion.value.id] !== undefined
const isSkippable = quizData.value?.is_skippable const isSkippable = quizData.value?.is_skippable
@ -88,12 +91,12 @@ const jumpToQuestion = (targetIndex: number) => {
} }
} }
// (If jumping backward? Usually allowed freely.) // If jumping backward? Usually allowed freely.
currentQuestionIndex.value = targetIndex currentQuestionIndex.value = targetIndex
} }
// Computed (Computed Properties) // Computed
const currentQuestion = computed(() => { const currentQuestion = computed(() => {
if (!quizData.value || !quizData.value.questions) return null if (!quizData.value || !quizData.value.questions) return null
return quizData.value.questions[currentQuestionIndex.value] return quizData.value.questions[currentQuestionIndex.value]
@ -113,7 +116,7 @@ const timerDisplay = computed(() => {
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}) })
// (Helper for localization) // Helper for localization
const getLocalizedText = (text: any) => { const getLocalizedText = (text: any) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
@ -123,7 +126,7 @@ const getLocalizedText = (text: any) => {
const lessonProgress = ref<any>(null) const lessonProgress = ref<any>(null)
// (Data Fetching) // Data Fetching
const loadData = async () => { const loadData = async () => {
isLoading.value = true isLoading.value = true
try { try {
@ -135,9 +138,9 @@ const loadData = async () => {
if (courseId && lessonId) { if (courseId && lessonId) {
const lessonRes = await fetchLessonContent(courseId, lessonId) const lessonRes = await fetchLessonContent(courseId, lessonId)
if (lessonRes.success) { 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 quizData.value = lessonRes.data.quiz || lessonRes.data
lessonProgress.value = lessonRes.progress // (Capture progress) lessonProgress.value = lessonRes.progress // Capture progress
if (quizData.value?.time_limit) { if (quizData.value?.time_limit) {
timeLeft.value = quizData.value.time_limit * 60 timeLeft.value = quizData.value.time_limit * 60
} }
@ -150,7 +153,7 @@ const loadData = async () => {
} }
} }
// (Helper for shuffling) // Helper for shuffling
const shuffleArray = <T>(array: T[]): T[] => { const shuffleArray = <T>(array: T[]): T[] => {
return array return array
.map(value => ({ value, sort: Math.random() })) .map(value => ({ value, sort: Math.random() }))
@ -158,18 +161,18 @@ const shuffleArray = <T>(array: T[]): T[] => {
.map(({ value }) => value) .map(({ value }) => value)
} }
// (Quiz Actions) // Quiz Actions
const startQuiz = () => { const startQuiz = () => {
// (Deep copy to reset and apply shuffle) // Deep copy to reset and apply shuffle
const rawQuiz = JSON.parse(JSON.stringify(quizData.value)) const rawQuiz = JSON.parse(JSON.stringify(quizData.value))
if (rawQuiz) { if (rawQuiz) {
// (Shuffle Questions) // Shuffle Questions
if (rawQuiz.shuffle_questions && rawQuiz.questions) { if (rawQuiz.shuffle_questions && rawQuiz.questions) {
rawQuiz.questions = shuffleArray(rawQuiz.questions) rawQuiz.questions = shuffleArray(rawQuiz.questions)
} }
// (Shuffle Choices) // Shuffle Choices
if (rawQuiz.shuffle_choices && rawQuiz.questions) { if (rawQuiz.shuffle_choices && rawQuiz.questions) {
rawQuiz.questions.forEach((q: any) => { rawQuiz.questions.forEach((q: any) => {
if (q.choices) { if (q.choices) {
@ -177,7 +180,7 @@ const startQuiz = () => {
} }
}) })
} }
// (Update state with shuffled data) // Update state with shuffled data
quizData.value = rawQuiz quizData.value = rawQuiz
} }
@ -193,7 +196,7 @@ const startQuiz = () => {
}, 1000) }, 1000)
} }
// (Mark first as visited) // Mark first as visited
visitedQuestions.value = new Set([0]) visitedQuestions.value = new Set([0])
} }
@ -206,12 +209,12 @@ const selectAnswer = (choiceId: number) => {
const nextQuestion = () => { const nextQuestion = () => {
if (!currentQuestion.value) return 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 isAnswered = userAnswers.value[currentQuestion.value.id] !== undefined
const isSkippable = quizData.value?.is_skippable const isSkippable = quizData.value?.is_skippable
if (!isAnswered && !isSkippable) { if (!isAnswered && !isSkippable) {
// (Show warning) // Show warning
$q.notify({ $q.notify({
type: 'warning', type: 'warning',
message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'), message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'),
@ -238,7 +241,7 @@ const retryQuiz = () => {
} }
const submitQuiz = async (auto = false) => { 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) { if (!auto) {
const answeredCount = Object.keys(userAnswers.value).length const answeredCount = Object.keys(userAnswers.value).length
if (answeredCount < totalQuestions.value) { if (answeredCount < totalQuestions.value) {
@ -251,7 +254,7 @@ const submitQuiz = async (auto = false) => {
return return
} }
// (Premium Confirmation before submission) // Premium Confirmation before submission
$q.dialog({ $q.dialog({
title: `<div class="text-slate-900 dark:text-white font-black text-xl">${t('quiz.warningTitle')}</div>`, 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>`, message: `<div class="text-slate-600 dark:text-slate-300 text-base leading-relaxed mt-2">${t('quiz.submitConfirm')}</div>`,
@ -282,33 +285,33 @@ const submitQuiz = async (auto = false) => {
} }
const processSubmitQuiz = async (auto = false) => { const processSubmitQuiz = async (auto = false) => {
// 2. (Start Submission Process) // 2. Start Submission Process
if (timerInterval) clearInterval(timerInterval) if (timerInterval) clearInterval(timerInterval)
isSubmitting.value = true isSubmitting.value = true
currentScreen.value = 'result' // (Switch to result screen to show progress) currentScreen.value = 'result' // Switch to result screen to show progress
try { try {
// API (Prepare Payload) // Prepare Payload
const answersPayload = Object.entries(userAnswers.value).map(([qId, cId]) => ({ const answersPayload = Object.entries(userAnswers.value).map(([qId, cId]) => ({
question_id: Number(qId), question_id: Number(qId),
choice_id: cId choice_id: cId
})) }))
// (Check if already passed) // Check if already passed
const alreadyPassed = lessonProgress.value?.is_passed || lessonProgress.value?.is_completed || false const alreadyPassed = lessonProgress.value?.is_passed || lessonProgress.value?.is_completed || false
// API (Call API) // Call API
const res = await apiSubmitQuiz(courseId, lessonId, answersPayload, alreadyPassed) const res = await apiSubmitQuiz(courseId, lessonId, answersPayload, alreadyPassed)
if (res.success && res.data) { if (res.success && res.data) {
quizResult.value = 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 (res.data.is_passed && !alreadyPassed) {
if (lessonProgress.value) lessonProgress.value.is_passed = true if (lessonProgress.value) lessonProgress.value.is_passed = true
} }
} else { } else {
// (Fallback error handling) // Fallback error handling
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: res.error || 'Failed to submit quiz' message: res.error || 'Failed to submit quiz'
@ -361,14 +364,14 @@ const reviewQuiz = () => {
currentScreen.value = 'review' currentScreen.value = 'review'
} }
// ID (A, B, C...) (Helper to get choice label (A, B, C...)) // Helper to get choice label (A, B, C...)
const getChoiceLabel = (index: number) => { const getChoiceLabel = (index: number) => {
return String.fromCharCode(65 + index) // 65 is 'A' return String.fromCharCode(65 + index) // 65 is 'A'
} }
const getCorrectChoiceId = (questionId: number) => { const getCorrectChoiceId = (questionId: number) => {
if (!quizResult.value?.answers_review) return null if (!quizResult.value?.answers_review) return null
// (Type checking for safety) // Type checking for safety
const review = Array.isArray(quizResult.value.answers_review) const review = Array.isArray(quizResult.value.answers_review)
? quizResult.value.answers_review.find((r: any) => r.question_id === questionId) ? quizResult.value.answers_review.find((r: any) => r.question_id === questionId)
: null : null
@ -381,7 +384,7 @@ const getCorrectChoiceId = (questionId: number) => {
<q-page-container> <q-page-container>
<q-page> <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"> <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"> <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 w-full justify-between">
<div class="flex items-center"> <div class="flex items-center">
@ -407,7 +410,7 @@ const getCorrectChoiceId = (questionId: number) => {
</div> </div>
</header> </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"> <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"> <div v-if="isLoading" class="flex flex-col items-center gap-4">
@ -432,9 +435,9 @@ const getCorrectChoiceId = (questionId: number) => {
</div> </div>
<template v-else> <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"> <div v-if="currentScreen === 'start'" class="w-full max-w-[640px] animate-fade-in py-12">
<!-- ... (หนาแรกยงคงเหมอนเด แปะไวเป reference) ... --> <!-- ... (Start Screen is unchanged but needs to be here for context) ... -->
<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="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"> <div class="flex justify-center mb-10">
@ -456,7 +459,7 @@ const getCorrectChoiceId = (questionId: number) => {
</p> </p>
</div> </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"> <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"> <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') }} {{ $t('quiz.instructionTitle') }}
@ -480,16 +483,17 @@ const getCorrectChoiceId = (questionId: number) => {
</div> </div>
</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="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"> <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="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 class="h-full bg-blue-500 transition-all duration-300" :style="{ width: ((currentQuestionIndex + 1) / totalQuestions) * 100 + '%' }"></div>
</div> </div>
<!-- แผนทคำถาม / การเปลยนหน (Question Map / Pagination) --> <!-- Question Map / Pagination -->
<div class="flex flex-wrap gap-2 mb-8 mt-4"> <div class="flex flex-wrap gap-2 mb-8 mt-4">
<button <button
v-for="(q, idx) in quizData?.questions" v-for="(q, idx) in quizData?.questions"
@ -502,12 +506,12 @@ const getCorrectChoiceId = (questionId: number) => {
</button> </button>
</div> </div>
<!-- อคำถาม (Question Title) --> <!-- Question Title -->
<h3 class="text-xl md:text-2xl font-bold text-slate-900 dark:text-white mb-10 leading-relaxed"> <h3 class="text-xl md:text-2xl font-bold text-slate-900 dark:text-white mb-10 leading-relaxed">
{{ getLocalizedText(currentQuestion.question) }} {{ getLocalizedText(currentQuestion.question) }}
</h3> </h3>
<!-- วนการเลอกคำตอบ (Choices) --> <!-- Choices -->
<div class="flex flex-col gap-4 mb-12"> <div class="flex flex-col gap-4 mb-12">
<button <button
v-for="choice in currentQuestion.choices" v-for="choice in currentQuestion.choices"
@ -529,7 +533,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"> <div class="flex justify-between items-center pt-8 border-t border-slate-100 dark:border-white/5">
<button <button
@click="prevQuestion" @click="prevQuestion"
@ -559,7 +563,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 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"> <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">
@ -582,7 +586,7 @@ const getCorrectChoiceId = (questionId: number) => {
</p> </p>
</div> </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="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-center">
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.scoreLabel') }}</div> <div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.scoreLabel') }}</div>
@ -622,7 +626,7 @@ const getCorrectChoiceId = (questionId: number) => {
</div> </div>
</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 v-if="currentScreen === 'review'" class="w-full max-w-[840px] animate-fade-in py-12 pb-24">
<div class="space-y-6"> <div class="space-y-6">
<div <div
@ -648,7 +652,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) '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 <div
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 font-bold text-sm border-2" class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 font-bold text-sm border-2"
:class="{ :class="{
@ -664,7 +668,7 @@ const getCorrectChoiceId = (questionId: number) => {
<span class="font-medium text-slate-700 dark:text-slate-300">{{ getLocalizedText(choice.text) }}</span> <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"> <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') }} {{ $t('quiz.correctLabel', 'Correct') }}
</div> </div>
@ -691,11 +695,11 @@ const getCorrectChoiceId = (questionId: number) => {
</main> </main>
</div> <!-- ดสวนเปลอกขอสอบ (Close quiz-shell) --> </div> <!-- Close quiz-shell -->
<!-- อปอปแผนทคำถาม (เดสกอป) - อยนอกพนททำงานปกต (Question Navigator Sidebar/Floating (Desktop) - Outside Main Flow) --> <!-- Question Navigator Sidebar/Floating (Desktop) - Outside Main Flow -->
<!-- ใชงาน QPageSticky ใหกท (Using QPageSticky properly inside q-page/q-layout context we added) --> <!-- Using QPageSticky properly inside q-page/q-layout context we added -->
<q-page-sticky <q-page-sticky
v-if="false" v-if="false"
position="top-right" position="top-right"

View file

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

View file

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

View file

@ -1,46 +1,44 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file index.vue * @file index.vue
* @description หนาหลกแดชบอร (Dashboard Home) * @description Dashboard Home Page matching FutureSkill design
*/ */
// 1. MetaData
definePageMeta({ definePageMeta({
layout: "default", layout: "default",
middleware: "auth", middleware: "auth",
}); });
useHead({ useHead({
title: "Dashboard - e-Learning Platform", title: "Dashboard - FutureSkill Clone",
}); });
// 2. Composables
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse(); const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse();
const { fetchCategories } = useCategory(); const { fetchCategories } = useCategory();
const { t } = useI18n(); const { t } = useI18n();
// 3. (State) // State
const enrolledCourses = ref<any[]>([]); const enrolledCourses = ref<any[]>([]);
const recommendedCourses = ref<any[]>([]); const recommendedCourses = ref<any[]>([]);
const libraryCourses = ref<any[]>([]); const libraryCourses = ref<any[]>([]);
const categories = ref<any[]>([]); const categories = ref<any[]>([]);
const isLoading = ref(true); const isLoading = ref(true);
// 4. (Data Initialization) // Initial Data Fetch
onMounted(async () => { onMounted(async () => {
isLoading.value = true; isLoading.value = true;
try { try {
const [catRes, enrollRes, courseRes, allCoursesRes] = await Promise.all([ const [catRes, enrollRes, courseRes] = await Promise.all([
fetchCategories(), fetchCategories(),
fetchEnrolledCourses({ limit: 10 }), // fetchEnrolledCourses({ limit: 10 }), // Fetch more enrolled courses for library section
fetchCourses({ fetchCourses({
limit: 3, limit: 3,
random: true, random: true,
forceRefresh: true, forceRefresh: true,
is_recommended: true, is_recommended: true,
}), // }), // Fetch 3 Recommended Courses
fetchCourses({ limit: 1000 }) //
]); ]);
if (catRes.success) { if (catRes.success) {
@ -50,44 +48,36 @@ onMounted(async () => {
const catMap = new Map(); const catMap = new Map();
categories.value.forEach((c: any) => catMap.set(c.id, c.name)); categories.value.forEach((c: any) => catMap.set(c.id, c.name));
const catIdMap = new Map(); // Map Enrolled Courses
if (allCoursesRes && allCoursesRes.success && allCoursesRes.data) {
allCoursesRes.data.forEach((c: any) => catIdMap.set(c.id, c.category_id));
}
// (Mapping Enrolled Courses)
if (enrollRes.success && enrollRes.data) { if (enrollRes.success && enrollRes.data) {
// Sort by last_accessed_at descending (Newest first)
const sortedEnrollments = [...enrollRes.data].sort((a, b) => { const sortedEnrollments = [...enrollRes.data].sort((a, b) => {
const dateA = new Date(a.last_accessed_at || a.enrolled_at).getTime(); const dateA = new Date(a.last_accessed_at || a.enrolled_at).getTime();
const dateB = new Date(b.last_accessed_at || b.enrolled_at).getTime(); const dateB = new Date(b.last_accessed_at || b.enrolled_at).getTime();
return dateB - dateA; return dateB - dateA;
}); });
enrolledCourses.value = sortedEnrollments.map((item: any) => { enrolledCourses.value = sortedEnrollments.map((item: any) => ({
const mappedCategoryId = catIdMap.get(item.course.id) || item.course.category_id;
return {
id: item.course_id, id: item.course_id,
title: item.course.title, title: item.course.title,
thumbnail_url: item.course.thumbnail_url, thumbnail_url: item.course.thumbnail_url,
progress: item.progress_percentage || 0, progress: item.progress_percentage || 0,
total_lessons: item.course.total_lessons || 0, total_lessons: item.course.total_lessons || 10,
completed_lessons: Math.floor( completed_lessons: Math.floor(
(item.progress_percentage / 100) * (item.course.total_lessons || 0), (item.progress_percentage / 100) * (item.course.total_lessons || 10),
), ),
category: catMap.get(mappedCategoryId), // For CourseCard compatibility in library section
category: catMap.get(item.course.category_id),
lessons: item.course.total_lessons || 0, lessons: item.course.total_lessons || 0,
image: item.course.thumbnail_url, image: item.course.thumbnail_url,
enrolled: true, enrolled: true,
instructor: item.course.creator || item.course.instructor, }));
last_accessed: item.last_accessed_at || item.enrolled_at
};
});
// Update libraryCourses with only 2 courses
libraryCourses.value = enrolledCourses.value.slice(0, 2); libraryCourses.value = enrolledCourses.value.slice(0, 2);
} }
// (Mapping Recommended Courses) // Map Recommended Courses
if (courseRes.success && courseRes.data) { if (courseRes.success && courseRes.data) {
recommendedCourses.value = courseRes.data.map((c: any) => ({ recommendedCourses.value = courseRes.data.map((c: any) => ({
id: c.id, id: c.id,
@ -108,215 +98,328 @@ onMounted(async () => {
} }
}); });
// 5. Computed // Helper for "Continue Learning" Hero Card
const heroCourse = computed(() => enrolledCourses.value[0] || null); const heroCourse = computed(() => enrolledCourses.value[0] || null);
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3)); const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
const navigateToCategory = (catName: string) => {
const cat = categories.value.find(c => getLocalizedText(c.name) === catName);
if (cat) {
navigateTo(`/browse/discovery?category_id=${cat.id}`);
} else {
navigateTo(`/browse/discovery`);
}
}
</script> </script>
<template> <template>
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen font-inter p-4 md:p-8 transition-colors duration-300"> <div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen font-inter pb-20 transition-colors duration-300">
<div class="max-w-[1400px] mx-auto grid grid-cols-1 xl:grid-cols-3 gap-8"> <div class="container mx-auto px-6 md:px-12 space-y-16 mt-10">
<!-- 1. Dashboard Hero Banner (Refined) -->
<section
class="relative overflow-hidden bg-gradient-to-br from-white to-slate-50 dark:from-slate-900 dark:to-slate-950 rounded-[2rem] py-10 md:py-14 px-8 md:px-12 shadow-sm border border-slate-100 dark:border-slate-800 flex flex-col items-center text-center transition-colors duration-300"
>
<!-- Subtle Decorative Elements -->
<div
class="absolute top-[-20%] left-[-10%] w-[300px] h-[300px] bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-3xl -z-10"
/>
<div
class="absolute bottom-[-20%] right-[-10%] w-[300px] h-[300px] bg-indigo-500/5 dark:bg-indigo-500/10 rounded-full blur-3xl -z-10"
/>
<!-- คอลมนาย (เนอหาหล) --> <div class="max-w-2xl space-y-6 relative z-10">
<div class="xl:col-span-2 space-y-6"> <h1
class="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 dark:text-white leading-[1.5] tracking-tight"
>
{{ $t("dashboard.heroTitle") }}
<span class="inline-block text-blue-600 dark:text-blue-400 mt-1 md:mt-2">{{
$t("dashboard.heroSubtitle")
}}</span>
</h1>
<!-- ายตอนร (Welcome Banner) --> <p
<div class="bg-[#3B6BE8] rounded-[2rem] p-6 md:p-10 relative overflow-hidden text-white shadow-[0_8px_30px_rgb(59,107,232,0.2)]"> class="text-slate-500 dark:text-slate-400 font-medium text-base md:text-lg max-w-xl mx-auto leading-relaxed"
<!-- ลวดลายพนหลงและดาวตกแต --> >
<div class="absolute inset-0 bg-grid-pattern opacity-10 md:opacity-20 pointer-events-none"></div> {{ $t("dashboard.heroDesc") }}
<div class="absolute right-5 md:right-10 top-1/2 -translate-y-1/2 w-20 h-20 md:w-28 md:h-28 border border-white/20 rounded-[1.5rem] md:rounded-[2rem] flex items-center justify-center rotate-12 bg-white/5 backdrop-blur-sm opacity-30 md:opacity-100">
<q-icon name="auto_awesome" size="32px" md-size="48px" class="text-white" />
</div>
<div class="relative z-10 max-w-lg">
<h1 class="text-2xl md:text-4xl font-bold mb-2 md:mb-3 tracking-tight">{{ $t('dashboard.welcomeTitle') }} {{ currentUser?.firstName || 'User' }} !</h1>
<p class="text-blue-100/90 text-[13px] md:text-[15px] leading-relaxed mb-6 md:mb-8 font-medium">
{{ $t('dashboard.welcomeSubtitle') }}
</p> </p>
<button @click="navigateTo('/browse/discovery')" class="bg-white text-[#3B6BE8] font-bold px-5 py-2.5 md:px-6 md:py-3 rounded-full text-xs md:text-sm flex items-center gap-2 hover:bg-slate-50 hover:scale-105 shadow-md transition-all">
{{ $t('dashboard.moreCourses') }} <q-icon name="chevron_right" size="16px" />
</button>
</div>
</div>
<!-- การดหมวดหมวน (3 Stats Cards) --> <div class="flex flex-wrap justify-center gap-4 pt-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> <q-btn
<!-- การออกแบบ --> unelevated
<div @click="navigateToCategory('การออกแบบ')" class="bg-white dark:!bg-slate-900 rounded-[1.5rem] p-5 flex items-center gap-4 shadow-sm border border-slate-100 dark:border-slate-800 transition-all hover:scale-105 hover:shadow-md cursor-pointer"> rounded
<div class="w-12 h-12 rounded-2xl bg-[#E9EFFD] dark:bg-blue-900/30 text-[#3B6BE8] flex items-center justify-center shrink-0"> color="primary"
<q-icon name="palette" size="24px" /> :label="$t('dashboard.goToMyCourses')"
</div> class="px-8 h-[48px] font-bold no-caps shadow-lg shadow-blue-500/10 hover:-translate-y-0.5 transition-all text-sm"
<div> to="/dashboard/my-courses"
<h3 class="font-bold text-slate-800 dark:text-slate-200 text-sm">{{ $t('discovery.design') }}</h3> />
</div> <q-btn
</div> outline
<!-- การเขยนโปรแกรม --> rounded
<div @click="navigateToCategory('การเขียนโปรแกรม')" class="bg-white dark:!bg-slate-900 rounded-[1.5rem] p-5 flex items-center gap-4 shadow-sm border border-slate-100 dark:border-slate-800 transition-all hover:scale-105 hover:shadow-md cursor-pointer"> color="primary"
<div class="w-12 h-12 rounded-2xl bg-[#FFF3EB] dark:bg-orange-900/30 text-[#FF8A4C] flex items-center justify-center shrink-0"> :label="$t('dashboard.searchNewCourses')"
<q-icon name="code" size="24px" /> class="px-8 h-[48px] font-bold no-caps hover:bg-white dark:hover:bg-slate-800 transition-all border-1 text-sm dark:text-white dark:border-slate-600"
</div> style="border-width: 1.5px"
<div> to="/browse/discovery"
<h3 class="font-bold text-slate-800 dark:text-slate-200 text-sm">{{ $t('discovery.programming') }}</h3> />
</div>
</div>
<!-- รก -->
<div @click="navigateToCategory('ธุรกิจ')" class="bg-white dark:!bg-slate-900 rounded-[1.5rem] p-5 flex items-center gap-4 shadow-sm border border-slate-100 dark:border-slate-800 transition-all hover:scale-105 hover:shadow-md cursor-pointer">
<div class="w-12 h-12 rounded-2xl bg-[#EBFAF6] dark:bg-emerald-900/30 text-[#10B981] flex items-center justify-center shrink-0">
<q-icon name="work_outline" size="24px" />
</div>
<div>
<h3 class="font-bold text-slate-800 dark:text-slate-200 text-sm">{{ $t('discovery.business') }}</h3>
</div>
</div> </div>
</div> </div>
</section>
<!-- วนเรยนตอจากครงกอน (Continue Learning) --> <!-- 2. Continue Learning Section -->
<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"> <section v-if="enrolledCourses.length > 0">
<div class="flex items-center justify-between mb-6"> <div class="flex justify-between items-end mb-6">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('dashboard.continueLearningTitle') }}</h2> <h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white transition-colors">
<NuxtLink to="/dashboard/my-courses" class="text-[#3B6BE8] font-bold text-sm flex items-center gap-1 hover:underline"> {{ $t("dashboard.continueLearningTitle") }}
{{ $t('dashboard.viewAll') }} </h2>
<NuxtLink
to="/dashboard/my-courses"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium text-sm flex items-center gap-1 transition-colors"
>
{{ $t("dashboard.myCourses") }}
<q-icon name="arrow_forward" size="16px" />
</NuxtLink> </NuxtLink>
</div> </div>
<div v-if="heroCourse" class="bg-[#F8F9FA] dark:bg-slate-800/50 rounded-3xl p-4 md:p-6 flex flex-col md:flex-row gap-6 md:gap-8 items-center border border-slate-100 dark:border-slate-800"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="w-full md:w-[35%] aspect-[4/3] rounded-[1.5rem] overflow-hidden flex-shrink-0 bg-slate-200"> <!-- Hero Card (Left) -->
<img :src="heroCourse.thumbnail_url" class="w-full h-full object-cover" /> <div
</div> v-if="heroCourse"
class="relative group cursor-pointer rounded-2xl overflow-hidden bg-white dark:bg-[#1e293b] shadow-sm border border-gray-100 dark:border-slate-700 hover:shadow-md transition-all h-[320px]"
<div class="flex-1 w-full flex flex-col"> @click="
<div class="flex items-center justify-between mb-3"> navigateTo(`/classroom/learning?course_id=${heroCourse.id}`)
<!-- ายบอกหมวดหม (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> <img
</div> :src="heroCourse.thumbnail_url"
<div v-else></div> class="w-full h-full object-cover brightness-75 group-hover:brightness-90 transition-all duration-500"
<span class="text-slate-400 dark:text-slate-400 text-xs flex items-center gap-1.5 font-medium" v-if="heroCourse.last_accessed">
<q-icon name="schedule" size="14px" /> {{ $t('common.latest') }} {{ new Date(heroCourse.last_accessed).toLocaleDateString('th-TH', { day: 'numeric', month: 'short' }) }}
</span>
</div>
<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) -->
<div class="mb-6 mt-4">
<div class="flex justify-between text-[13px] font-bold mb-2">
<span class="text-[#3B6BE8] dark:text-blue-400">{{ $t('course.progress') }}: {{ heroCourse.progress || 0 }}%</span>
</div>
<div class="h-2.5 w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full bg-[#3B6BE8] rounded-full transition-all duration-500" :style="{ width: `${heroCourse.progress || 0}%` }"></div>
</div>
</div>
<div class="flex items-center justify-between mt-auto">
<div class="flex items-center gap-3" v-if="heroCourse.instructor">
<div class="w-10 h-10 rounded-full bg-orange-100 overflow-hidden shrink-0 border border-slate-200 dark:border-slate-700">
<img :src="heroCourse.instructor.profile_image_url || heroCourse.instructor.photoURL || `https://api.dicebear.com/7.x/avataaars/svg?seed=${heroCourse.instructor.username || 'Inst'}`" class="w-full h-full object-cover" />
</div>
<div>
<p class="font-bold text-slate-900 dark:text-white text-[13px] leading-tight mb-0.5">
{{ heroCourse.instructor.firstName || heroCourse.instructor.first_name ? `${heroCourse.instructor.firstName || heroCourse.instructor.first_name} ${heroCourse.instructor.lastName || heroCourse.instructor.last_name || ''}` : heroCourse.instructor.username || 'ผู้สอน' }}
</p>
<p class="text-slate-500 dark:text-slate-400 text-[11px] font-medium line-clamp-1">{{ heroCourse.instructor.bio || heroCourse.instructor.role?.name?.th || 'Instructor' }}</p>
</div>
</div>
<div v-else class="flex items-center gap-3"></div>
<button @click="navigateTo(`/classroom/learning?course_id=${heroCourse.id}`)" class="bg-[#3B6BE8] hover:bg-blue-700 text-white px-5 py-2.5 rounded-full font-bold text-sm flex items-center gap-2 shadow-lg shadow-blue-500/20 transition-all hover:scale-105 shrink-0">
<q-icon name="play_circle" size="18px" /> {{ $t('course.continueLearning') }}
</button>
</div>
</div>
</div>
<div v-else class="bg-[#F8F9FA] dark:bg-slate-800/50 rounded-3xl p-10 flex items-center justify-center text-slate-400 border border-dashed border-slate-200 dark:border-slate-700">
ไมคอรสเรยนปจจ
</div>
</div>
</div>
<!-- คอลมนขวา (แถบขอมลโปรไฟลและคอรสแนะนำ) -->
<div class="xl:col-span-1 space-y-6">
<!-- ดเจตโปรไฟลใช -->
<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) -->
<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">
<UserAvatar
:photo-u-r-l="currentUser?.photoURL"
:first-name="currentUser?.firstName || 'ผู้ใช้งาน'"
:last-name="currentUser?.lastName"
size="88"
class="rounded-full object-cover"
/> />
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent p-8 flex flex-col justify-end"
>
<h3
class="text-white text-2xl font-bold mb-4 line-clamp-2 leading-snug shadow-black/50 drop-shadow-sm"
>
{{ getLocalizedText(heroCourse.title) }}
</h3>
<!-- Progress -->
<div class="w-full">
<div class="flex justify-end text-gray-300 text-xs mb-2">
<span>{{ heroCourse.progress }}%</span>
</div>
<div
class="h-1.5 w-full bg-white/20 rounded-full overflow-hidden"
>
<div
class="h-full rounded-full transition-all duration-500"
:class="
heroCourse.progress === 100
? 'bg-emerald-500'
: 'bg-blue-500'
"
:style="{ width: `${heroCourse.progress}%` }"
></div>
</div>
<div class="mt-4 flex justify-end">
<span
class="font-bold text-sm hover:underline transition-colors"
:class="
heroCourse.progress === 100
? 'text-emerald-400'
: 'text-white'
"
>
{{
heroCourse.progress === 100
? $t("dashboard.studyAgain")
: $t("dashboard.continue")
}}
</span>
</div>
</div>
</div>
</div> </div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white relative z-10 tracking-tight"> <!-- Side List (Right) -->
{{ currentUser?.firstName ? `${currentUser.firstName} ${currentUser.lastName || ''}` : 'ผู้ใช้งาน' }} <div class="flex flex-col gap-4 h-[320px]">
<div
v-for="course in sideCourses"
:key="course.id"
class="flex-1 bg-white dark:bg-[#1e293b] rounded-2xl p-4 border border-gray-100 dark:border-slate-700 shadow-sm hover:shadow-md transition-all flex gap-4 items-center"
>
<div class="w-32 h-20 rounded-xl overflow-hidden flex-shrink-0">
<img
:src="course.thumbnail_url"
class="w-full h-full object-cover"
/>
</div>
<div
class="flex-grow min-w-0 flex flex-col justify-between h-full py-1"
>
<h4 class="text-gray-800 dark:text-slate-200 font-bold text-sm line-clamp-2 mb-2 transition-colors">
{{ getLocalizedText(course.title) }}
</h4>
<div class="mt-auto">
<div
class="h-1.5 w-full bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden mb-2"
>
<div
class="h-full rounded-full transition-all duration-500"
:class="
course.progress === 100
? 'bg-emerald-500'
: 'bg-blue-600'
"
:style="{ width: `${course.progress}%` }"
></div>
</div>
<div class="flex justify-end items-center text-xs">
<span
class="font-bold cursor-pointer hover:underline transition-colors"
:class="
course.progress === 100
? 'text-emerald-600 dark:text-emerald-400'
: 'text-blue-600 dark:text-blue-400'
"
@click="
navigateTo(`/classroom/learning?course_id=${course.id}`)
"
>
{{
course.progress === 100
? $t("dashboard.studyAgain")
: $t("dashboard.continue")
}}
</span>
</div>
</div>
</div>
</div>
<!-- Empty State Placeholder if less than 2 side courses -->
<div
v-if="sideCourses.length < 2"
class="flex-1 bg-gray-50 dark:bg-[#1e293b]/50 rounded-2xl border border-dashed border-gray-200 dark:border-slate-700 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm transition-colors"
>
{{ $t("dashboard.startNewCourse") }}
</div>
</div>
</div>
</section>
<!-- 3. Knowledge Library -->
<section>
<div class="mb-6">
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white mb-1 transition-colors">
{{ $t("dashboard.knowledgeLibrary") }}
</h2> </h2>
<p class="text-slate-500 dark:text-slate-400 text-xs mt-1 relative z-10 font-medium tracking-wide">{{ $t('common.student') }}</p> <p class="text-gray-500 dark:text-slate-400 text-sm transition-colors">
{{ $t("dashboard.libraryDesc") }}
</p>
</div>
<div class="flex w-full gap-3 mt-7 relative z-10 px-2"> <!-- Content when courses exist -->
<div class="flex-1 bg-[#F8FAFC] dark:bg-slate-800 rounded-2xl p-3.5 flex flex-col items-center justify-center transition-colors shadow-sm"> <div
<span class="text-[1.35rem] font-black text-[#3B6BE8] dark:text-blue-400 mb-1 leading-none">{{ String(enrolledCourses.length || 0).padStart(2, '0') }}</span> v-if="libraryCourses.length > 0"
<span class="text-slate-400 text-[10px] font-bold tracking-wider">{{ $t('myCourses.filterProgress') }}</span> class="grid grid-cols-1 md:grid-cols-3 gap-6"
</div> >
<div class="flex-1 bg-[#F8FAFC] dark:bg-slate-800 rounded-2xl p-3.5 flex flex-col items-center justify-center transition-colors shadow-sm"> <!-- Course Cards -->
<span class="text-[1.35rem] font-black text-[#10B981] dark:text-emerald-400 mb-1 leading-none z-10">{{ String(enrolledCourses.filter(c => c.progress >= 100).length || 0).padStart(2, '0') }}</span> <CourseCard
<span class="text-slate-400 text-[10px] font-bold tracking-wider z-10">{{ $t('myCourses.filterCompleted') }}</span> v-for="course in libraryCourses"
</div> :key="course.id"
v-bind="course"
:image="course.thumbnail_url"
hide-progress
hide-actions
class="h-full md:col-span-1"
/>
<!-- CTA Card (Large) -->
<div
class="bg-white dark:bg-[#1e293b] rounded-3xl border border-gray-100 dark:border-slate-700 shadow-sm p-8 flex flex-col items-center justify-center text-center h-full min-h-[300px] hover:shadow-md transition-all group"
>
<p class="text-gray-600 dark:text-slate-300 font-medium mb-6 mt-4 transition-colors">
{{ $t("dashboard.chooseLibrary") }}
</p>
<q-btn
flat
rounded
no-caps
class="text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 px-6 py-2 font-bold group-hover:scale-105 transition-transform"
to="/dashboard/my-courses"
>
{{ $t("dashboard.viewAll") }}
<q-icon name="arrow_forward" size="18px" class="ml-2" />
</q-btn>
</div> </div>
</div> </div>
<!-- ดเจตคอรสแนะนำ --> <!-- Empty State when no courses -->
<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"> <div
<h2 class="text-[1.1rem] font-bold text-slate-900 dark:text-white mb-5 tracking-tight flex items-center justify-between"> v-else
{{ $t('dashboard.recommendedCourses') }} class="bg-white dark:bg-[#1e293b] rounded-3xl border border-dashed border-gray-200 dark:border-slate-700 p-12 flex flex-col items-center justify-center text-center min-h-[300px] transition-colors"
>
<div class="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-full mb-6 transition-colors">
<q-icon name="school" size="48px" class="text-blue-200 dark:text-blue-400" />
</div>
<h3 class="text-xl font-bold text-gray-800 dark:text-white mb-2 transition-colors">
{{ $t("dashboard.emptyLibraryTitle") }}
</h3>
<p class="text-gray-500 dark:text-slate-400 mb-8 max-w-md transition-colors">
{{ $t("dashboard.emptyLibraryDesc") }}
</p>
<q-btn
unelevated
rounded
no-caps
class="bg-blue-600 text-white px-8 py-3 font-bold hover:bg-blue-700 shadow-lg shadow-blue-500/20 transition-all hover:scale-105"
to="/browse/discovery"
>
{{ $t("dashboard.viewAllCourses") }}
</q-btn>
</div>
</section>
<!-- 5. Recommended Courses -->
<section class="pb-20">
<div class="mb-6">
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white text-left transition-colors">
{{ $t("dashboard.recommendedCourses") }}
</h2> </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) -->
<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) -->
<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">
<span class="text-slate-500 dark:text-slate-400 text-[11px] font-medium bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded text-ellipsis overflow-hidden whitespace-nowrap max-w-[80px]">{{ getLocalizedText(course.category) || 'อื่นๆ' }}</span>
<span v-if="course.is_free" class="text-[#10B981] font-bold text-[12px]">ฟรี</span>
<span v-else class="text-[#3B6BE8] font-bold text-[12px]">฿{{ Number(course.price).toLocaleString() }}</span>
</div>
</div>
</div>
</div> </div>
<button @click="navigateTo('/browse/discovery')" class="w-full mt-6 py-2.5 rounded-xl text-[13px] font-bold text-[#3B6BE8] dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"> <!-- Recommended Grid (3 columns) -->
{{ $t('dashboard.viewAllCourses') }} <div
</button> class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in"
>
<CourseCard
v-for="course in recommendedCourses"
:key="course.id"
v-bind="course"
/>
</div> </div>
<!-- Loading State -->
<div
v-if="recommendedCourses.length === 0 && !isLoading"
class="flex justify-center py-10 opacity-50"
>
<div class="text-gray-400 dark:text-slate-500">{{ $t("dashboard.noRecommended") }}</div>
</div> </div>
</section>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.bg-grid-pattern { /* Scoped specific styles */
background-image: .animate-fade-in {
linear-gradient(to right, rgba(255,255,255,0.08) 1px, transparent 1px), animation: fadeIn 0.5s ease-out;
linear-gradient(to bottom, rgba(255,255,255,0.08) 1px, transparent 1px); }
background-size: 24px 24px; @keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:deep(.q-btn) {
text-transform: none; /* Prevent uppercase in Q-Btns */
} }
</style> </style>

View file

@ -1,369 +1,297 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file my-courses.vue * @file my-courses.vue
* @description หนาคอรสของฉ (My Enrolled Courses) * @description My Courses Page.
* Displays enrolled courses with filters for progress/completed.
* Handles enrollment success modals and certificate downloads.
*/ */
// 1. MetaData
definePageMeta({ definePageMeta({
layout: 'default', layout: 'default',
middleware: 'auth' middleware: 'auth'
}) })
const { t, locale } = useI18n() useHead({
title: 'คอร์สของฉัน - e-Learning'
})
const route = useRoute() const route = useRoute()
const quasar = useQuasar()
useHead({ title: `${t('sidebar.myCourses') || 'My Courses'} - e-Learning Platform` })
// 2. Composables
const { fetchEnrolledCourses, getCertificate, generateCertificate, fetchCourses } = useCourse()
const { fetchCategories } = useCategory()
// 3. (State)
const enrolledCourses = ref<any[]>([])
const allCategories = ref<any[]>([])
const isLoading = ref(false)
const searchQuery = ref('')
const activeCategory = ref<number | 'all'>('all')
const viewMode = ref<'grid' | 'list'>('grid')
const showEnrollModal = ref(false) const showEnrollModal = ref(false)
const activeFilter = ref<'all' | 'progress' | 'completed'>('all')
const searchQuery = ref('')
// 4. (Helper Functions)
const getLocalizedText = (text: any) => { // Check URL query parameters to show 'Enrollment Success' modal
onMounted(() => {
if (route.query.enrolled) {
showEnrollModal.value = true
}
})
const { locale } = useI18n()
// Helper to get localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
const currentLocale = locale.value as 'th' | 'en' const currentLocale = locale.value as 'th' | 'en'
return text[currentLocale] || text.th || text.en || '' return text[currentLocale] || text.th || text.en || ''
} }
const getCategoryIcon = (name: any) => { // Data Handling
const text = getLocalizedText(name) || '' const { fetchEnrolledCourses, getCertificate, generateCertificate } = useCourse()
if (text.includes('เว็บ') || text.includes('Web') || text.includes('โปรแกรม') || text.includes('Program') || text.includes('โค้ด')) return 'code' const enrolledCourses = ref<any[]>([])
if (text.includes('ออกแบบ') || text.includes('Design') || text.includes('UI')) return 'palette' const isLoading = ref(false)
if (text.includes('ธุรกิจ') || text.includes('Business') || text.includes('การตลาด') || text.includes('Market')) return 'trending_up' const isDownloadingCert = ref(false)
if (text.includes('ข้อมูล') || text.includes('Data') || text.includes('วิเคราะ') || text.includes('Sci')) return 'storage'
return 'category'
}
// 5. (Data Loading) const loadEnrolledCourses = async () => {
const loadData = async () => {
isLoading.value = true isLoading.value = true
try { // FIX: For 'progress' tab, we want both ENROLLED and IN_PROGRESS.
const [catRes, courseRes, allCoursesRes] = await Promise.all([ // Since API takes single status, we fetch ALL and filter locally for 'progress'.
fetchCategories(), const apiStatus = activeFilter.value === 'completed'
fetchEnrolledCourses({}), ? 'COMPLETED'
fetchCourses({ limit: 1000 }) : undefined // 'all' or 'progress' -> fetch all
])
if (catRes.success) allCategories.value = catRes.data || [] const res = await fetchEnrolledCourses({
const catMap = new Map() status: apiStatus
allCategories.value.forEach(c => catMap.set(c.id, c)) })
const catIdMap = new Map() if (res.success) {
if (allCoursesRes && allCoursesRes.success && allCoursesRes.data) { let courses = (res.data || [])
allCoursesRes.data.forEach((c: any) => catIdMap.set(c.id, c.category_id))
// Local filter to ensure UI consistency regardless of backend filtering
if (activeFilter.value === 'progress') {
courses = courses.filter(c => c.status !== 'COMPLETED')
} else if (activeFilter.value === 'completed') {
courses = courses.filter(c => c.status === 'COMPLETED')
} }
if (courseRes.success && courseRes.data) { enrolledCourses.value = courses.map(item => ({
enrolledCourses.value = courseRes.data.map(item => {
const mappedCategoryId = catIdMap.get(item.course.id) || item.course.category_id
const cat = catMap.get(mappedCategoryId)
// (Instructor Name Logic)
let instName = t('course.instructor')
let user = null;
if (item.course.instructors && item.course.instructors.length > 0) {
const primary = item.course.instructors.find((i: any) => i.is_primary);
user = primary ? primary.user : item.course.instructors[0].user;
} else {
user = item.course.creator || (item.course as any).instructor;
}
if (user?.profile?.first_name) {
instName = `${user.profile.first_name} ${user.profile.last_name || ''}`.trim();
} else if (user?.first_name) {
instName = `${user.first_name} ${user.last_name || ''}`.trim();
} else if (user?.username) {
instName = user.username;
}
return {
id: item.course_id, id: item.course_id,
enrollment_id: item.id, enrollment_id: item.id,
title: item.course.title, title: item.course.title,
progress: item.progress_percentage || 0, progress: item.progress_percentage || 0,
lessons: item.course.total_lessons || 10, lessons: item.course.total_lessons || 0,
completed: item.status === 'COMPLETED', completed: item.status === 'COMPLETED',
thumbnail_url: item.course.thumbnail_url, thumbnail_url: item.course.thumbnail_url
category_id: mappedCategoryId, }))
category_name: cat ? getLocalizedText(cat.name) : '',
instructor_name: instName
} }
})
}
} catch (err) {
console.error("Failed to load enrolled courses", err)
} finally {
isLoading.value = false isLoading.value = false
} }
}
// 6. Computed (Computed Properties) // Watch filter changes to reload
const uniqueCategories = computed(() => { watch(activeFilter, () => {
const ids = Array.from(new Set(enrolledCourses.value.map(c => c.category_id))) loadEnrolledCourses()
return allCategories.value.filter(c => ids.includes(c.id))
})
const inProgressCourses = computed(() => {
return enrolledCourses.value.filter(c => !c.completed && c.progress >= 0 && c.progress < 100).reverse()
}) })
// Client-side Search Filtering
const filteredEnrolledCourses = computed(() => { const filteredEnrolledCourses = computed(() => {
let result = enrolledCourses.value if (!searchQuery.value) return enrolledCourses.value
if (activeCategory.value !== 'all') {
result = result.filter(c => c.category_id === activeCategory.value)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase() const query = searchQuery.value.toLowerCase()
result = result.filter(c => getLocalizedText(c.title).toLowerCase().includes(query)) return enrolledCourses.value.filter(c => {
} const title = getLocalizedText(c.title).toLowerCase()
return result return title.includes(query)
}) })
})
onMounted(() => {
if (route.query.enrolled) {
showEnrollModal.value = true
}
loadEnrolledCourses()
})
// Certificate Handling
const downloadingCourseId = ref<number | null>(null)
// Certificate Handling
const downloadCertificate = async (course: any) => {
if (!course) return
downloadingCourseId.value = course.id
try {
// 1. Try to GET existing certificate
let res = await getCertificate(course.id)
// 2. If not found (or error), try to GENERATE new one
if (!res.success) {
res = await generateCertificate(course.id)
}
// 3. Handle Result
if (res.success && res.data) {
const cert = res.data
if (cert.download_url) {
window.open(cert.download_url, '_blank')
} else {
// Fallback if no URL but success (maybe show message)
console.warn('Certificate ready but no URL')
}
} else {
// Silent fail or minimal log, or maybe use a toast if available, but avoid $q if undefined
console.error(res.error || 'Failed to get certificate')
}
} catch (e) {
console.error(e)
} finally {
downloadingCourseId.value = null
}
}
const validCourseId = computed(() => { const validCourseId = computed(() => {
const cid = route.query.course_id const cid = route.query.course_id
if (!cid || cid === 'undefined' || cid === 'null' || cid === 'NaN') return null if (!cid || cid === 'undefined' || cid === 'null' || cid === 'NaN') return null
return cid return cid
}) })
// 7. (Actions)
const handleDownloadCertificate = async (courseId: number) => {
try {
quasar.notify({ message: t('common.loading') + '...', color: 'info' })
const genRes = await generateCertificate(courseId)
if (genRes.success && genRes.data?.download_url) {
window.open(genRes.data.download_url, '_blank')
} else {
throw new Error(genRes.error || t('common.error'))
}
} catch (err: any) {
quasar.notify({ message: err.message || t('common.error'), color: 'negative' })
}
}
// 8. Lifecycle Hooks
onMounted(() => {
if (route.query.enrolled) showEnrollModal.value = true
loadData()
})
</script> </script>
<template> <template>
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300"> <div class="page-container">
<div class="max-w-[1240px] mx-auto">
<!-- Section 1: เรยนตอจากครงกอน (Continue Learning) -->
<div v-if="inProgressCourses.length > 0 && !searchQuery" 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 mb-8 transition-all"> <!-- Page Header & Filters (Unified Layout) -->
<div class="flex items-center gap-2.5 mb-6"> <div class="mb-8">
<q-icon name="play_circle_outline" size="26px" class="text-[#3B6BE8]" /> <div class="flex items-start gap-4 mb-2">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('dashboard.continueLearningTitle') }}</h2> <span class="w-1.5 h-10 md:h-12 bg-blue-600 rounded-full shadow-lg shadow-blue-500/50 mt-1 flex-shrink-0"></span>
<div>
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
{{ $t('sidebar.myCourses') }}
</h1>
</div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mt-4">
<div v-for="course in inProgressCourses.slice(0, 2)" :key="course.id" class="border border-slate-100 dark:border-slate-800 rounded-3xl p-4 flex flex-col sm:flex-row gap-5 items-center bg-[#F8FAFC] dark:bg-slate-800/50 hover:border-blue-100 dark:hover:border-blue-900/50 transition-colors"> <!-- Filter Tabs (Horizontal Bar) -->
<!-- วนรปภาพหนาปก (Image) --> <div class="bg-white dark:bg-slate-900/50 p-1.5 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex items-center gap-1 shadow-sm">
<div class="w-full sm:w-[160px] h-[120px] rounded-[1.25rem] overflow-hidden bg-slate-200 shrink-0 relative group"> <q-btn
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"/> v-for="filter in ['all', 'progress', 'completed']"
<!-- เลเยอรเลนวโอดวน (Quick play overlay) --> :key="filter"
<div @click="navigateTo(`/classroom/learning?course_id=${course.id}`)" class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"> @click="activeFilter = filter as any"
<div class="bg-white/30 backdrop-blur-md rounded-full w-10 h-10 flex flex-col items-center justify-center"> flat
<q-icon name="play_arrow" color="white" size="20px" class="ml-0.5" /> rounded
dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
:class="activeFilter === filter ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'"
:label="$t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`)"
/>
</div> </div>
</div>
</div>
<!-- วนขอมลเนอหา (Info) -->
<div class="flex-1 flex flex-col justify-center min-w-0 py-1 w-full">
<div class="mb-2" v-if="course.category_name">
<span class="bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] dark:text-blue-400 px-3.5 py-1.5 rounded-full text-[10px] font-bold tracking-wide">{{ course.category_name }}</span>
</div>
<h3 class="font-bold text-slate-900 dark:text-white text-[14px] leading-snug line-clamp-2 mb-4 pr-2">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center justify-between gap-4 mt-auto"> <!-- Search Input -->
<div class="flex-1"> <div class="w-full md:w-72">
<div class="flex items-center gap-2 text-[11px] font-bold text-slate-700 dark:text-slate-300 mb-1.5 tracking-wide"> <q-input
{{ $t('course.progress') }}: {{ course.progress }}% v-model="searchQuery"
</div> dense
<div class="h-[6px] w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden"> outlined
<div class="h-full bg-[#3B6BE8] rounded-full transition-all duration-500" :style="{ width: `${course.progress}%` }"></div> rounded
</div> :placeholder="$t('discovery.searchPlaceholder')"
</div> class="search-input shadow-sm"
<button @click="navigateTo(`/classroom/learning?course_id=${course.id}`)" class="bg-[#3B6BE8] hover:bg-blue-700 text-white rounded-full px-5 py-2 text-[12px] font-bold shrink-0 shadow-md shadow-blue-500/20 transition-transform hover:scale-105 outline-none">{{ $t('dashboard.continue') }}</button> bg-color="transparent"
</div> >
</div> <template v-slot:prepend>
<q-icon name="search" class="text-slate-400" />
</template>
</q-input>
</div> </div>
</div> </div>
</div> </div>
<!-- วนท 2: คอรสของฉ (My Courses) --> <!-- Courses Grid -->
<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"> <div v-if="isLoading" class="flex justify-center py-20">
<!-- วนหวและการคนหา -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('myCourses.title') }}</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('myCourses.searchPlaceholder')" />
</div>
<div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
</div>
</div>
</div>
<!-- วกรองหมวดหม (แบบเลอนแนวนอนบนมอถ) -->
<div class="mb-8 w-full overflow-hidden">
<div class="flex flex-nowrap items-center gap-3 overflow-x-auto scrollbar-hide pb-2 -mx-1 px-1">
<button
@click="activeCategory = 'all'"
:class="activeCategory === 'all' ? 'bg-[#3B6BE8] text-white border-transparent' : 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 hover:border-slate-300'"
class="px-5 py-2.5 rounded-xl border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none whitespace-nowrap font-bold shadow-sm">
<q-icon name="apps" size="18px" /> {{ $t('myCourses.filterAll') }}
</button>
<button
v-for="cat in uniqueCategories" :key="cat.id"
@click="activeCategory = cat.id"
:class="activeCategory === cat.id ? 'bg-[#3B6BE8] text-white border-transparent' : 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 hover:border-slate-300'"
class="px-5 py-2.5 rounded-xl border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none whitespace-nowrap font-bold shadow-sm">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" />
{{ getLocalizedText(cat.name) }}
</button>
</div>
</div>
<!-- Active View -->
<div v-if="isLoading" class="flex justify-center py-24">
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div v-else-if="filteredEnrolledCourses.length > 0"> <template v-for="course in filteredEnrolledCourses" :key="course.id">
<!-- In Progress Course Card -->
<!-- หมวดแสดงสไตลกรดตาราง (GRID VIEW) --> <CourseCard
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> v-if="!course.completed"
<div v-for="course in filteredEnrolledCourses" :key="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-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="navigateTo(`/classroom/learning?course_id=${course.id}`)"> :id="course.id"
<!-- วนรปภาพหนาปก (Thumbnail) --> :title="course.title"
<div class="relative w-full aspect-[4/3] bg-slate-100 dark:bg-slate-800 overflow-hidden"> :progress="course.progress"
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" /> :image="course.thumbnail_url"
<!-- ายหมวดหมบนมมซายของร (Badge inside Image Map Top Left) --> show-continue
<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"> :show-view-details="false"
{{ course.category_name }} />
</div> <!-- Completed Course Card -->
<CourseCard
v-else
:id="course.id"
:title="course.title"
:progress="100"
:image="course.thumbnail_url"
:completed="true"
show-certificate
show-study-again
:show-view-details="false"
:loading="downloadingCourseId === course.id"
@view-certificate="downloadCertificate(course)"
/>
</template>
</div> </div>
<!-- วการ (Card Body) --> <!-- Empty State -->
<div class="p-5 flex flex-col flex-1"> <div v-if="!isLoading && filteredEnrolledCourses.length === 0" class="flex flex-col items-center justify-center py-20 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 mt-4">
<h3 class="font-bold text-slate-900 dark:text-white text-[14px] leading-snug line-clamp-2 mb-3">{{ getLocalizedText(course.title) }}</h3> <q-icon v-if="searchQuery" 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 ? $t('discovery.emptyTitle') : $t('myCourses.emptyTitle') }}
</h3>
<div class="mt-auto flex items-center justify-between gap-4"> <p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
<div class="flex-1"> {{ searchQuery ? $t('discovery.emptyDesc') : $t('myCourses.emptyDesc') }}
<div class="text-[10px] font-bold text-slate-700 dark:text-slate-300 mb-1.5 tracking-wide">{{ $t('course.progress') }}: {{ course.progress }}%</div> </p>
<div class="h-[6px] w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden"> <NuxtLink v-if="!searchQuery" to="/browse/discovery" class="mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">{{ $t('myCourses.goToDiscovery') }}</NuxtLink>
<div class="h-full rounded-full transition-all duration-500" :class="course.completed ? 'bg-green-500' : 'bg-[#3B6BE8] dark:bg-blue-400'" :style="{ width: `${course.progress}%` }"></div> <button v-else class="mt-4 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''">
</div> {{ $t('discovery.showAll') }}
</div>
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mt-3 sm:mt-0">
<!-- มดาวนโหลดใบเซอร (Certificate Button) -->
<button v-if="course.completed" @click.stop="handleDownloadCertificate(course.id)" class="border border-green-100 bg-green-50 text-green-600 dark:border-green-900/50 dark:bg-green-900/30 dark:text-green-400 rounded-full px-3 py-1.5 text-[11px] font-bold hover:bg-green-100 dark:hover:bg-green-900/50 transition-colors shrink-0 flex items-center justify-center gap-1">
<q-icon name="workspace_premium" size="14px" /> {{ $t('course.certificate') }}
</button>
<!-- มเรยนตอหรอเรยนซ (Continue/Replay Button) -->
<button class="bg-[#3B6BE8] text-white border-transparent hover:bg-blue-700 shadow-sm rounded-full px-5 py-1.5 text-[11px] font-bold transition-colors shrink-0 text-center cursor-pointer">
{{ course.completed ? $t('course.studyAgain') : $t('dashboard.continue') }}
</button> </button>
</div> </div>
</div>
</div>
</div>
</div>
<!-- หมวดแสดงสไตลบรรทดรายการ (LIST VIEW) --> <!-- MODAL: Enrollment Success -->
<div v-else class="flex flex-col gap-4">
<div v-for="course in filteredEnrolledCourses" :key="course.id" class="flex flex-col sm:flex-row items-center rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:border-slate-800 p-4 gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 cursor-pointer group" @click="navigateTo(`/classroom/learning?course_id=${course.id}`)">
<!-- วนภาพหนาปกดานซาย (Thumbnail Left) -->
<div class="relative w-full sm:w-[240px] aspect-[16/10] sm:aspect-auto sm:h-[130px] rounded-2xl bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<!-- ายหมวดหมในรปภาพ (Badge inside Image) -->
<div v-if="course.category_name" class="absolute top-2.5 left-2.5 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">
{{ course.category_name }}
</div>
</div>
<!-- เนอหาอยทางขวา (Content Right) -->
<div class="flex-1 w-full flex flex-col md:flex-row gap-6 md:items-center">
<div class="flex-1 min-w-0">
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-3 pr-4">{{ getLocalizedText(course.title) }}</h3>
</div>
<!-- โซนความคบหนาและปมกด (Progress and Button Zone) -->
<div class="flex md:flex-col items-center md:items-end justify-between md:justify-center gap-4 shrink-0 md:w-[200px]">
<div class="w-full max-w-[140px] md:max-w-full">
<div class="flex justify-between items-center text-[11px] font-bold text-slate-700 dark:text-slate-300 mb-2 tracking-wide">
<span>{{ $t('course.progress') }}:</span>
<span>{{ course.progress }}%</span>
</div>
<div class="h-[6px] w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="course.completed ? 'bg-green-500' : 'bg-[#3B6BE8] dark:bg-blue-400'" :style="{ width: `${course.progress}%` }"></div>
</div>
</div>
<div class="flex flex-col items-stretch md:items-end gap-2 mt-3 sm:mt-0 w-full sm:w-auto">
<!-- มดาวนโหลดใบเซอร (Certificate Button) -->
<button v-if="course.completed" @click.stop="handleDownloadCertificate(course.id)" class="border border-green-100 bg-green-50 text-green-600 dark:border-green-900/50 dark:bg-green-900/30 dark:text-green-400 rounded-full px-4 py-2 text-[12px] font-bold hover:bg-green-100 dark:hover:bg-green-900/50 transition-colors shrink-0 flex items-center justify-center gap-1 w-full sm:w-auto">
<q-icon name="workspace_premium" size="16px" /> {{ $t('course.downloadCertificate') }}
</button>
<!-- มเรยนตอหรอเรยนซ (Continue/Replay Button) -->
<button class="bg-[#3B6BE8] text-white border-transparent hover:bg-blue-700 shadow-sm rounded-full px-6 py-2 text-[12px] font-bold transition-colors shrink-0 text-center w-full sm:w-auto cursor-pointer">
{{ course.completed ? $t('course.studyAgain') : $t('dashboard.continue') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- กรณนหา/กรองแลวไมพบขอม (Empty filter state) -->
<div v-else class="flex flex-col items-center justify-center py-20">
<q-icon name="search_off" size="48px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-2">{{ $t('myCourses.searchNoResult') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm">{{ $t('myCourses.searchNoResultDesc') }}</p>
</div>
</div>
</div>
<!-- หนาตางแจงเตอน: ลงทะเบยนสำเร (MODAL: Enrollment Success) -->
<q-dialog v-model="showEnrollModal" backdrop-filter="blur(4px)"> <q-dialog v-model="showEnrollModal" backdrop-filter="blur(4px)">
<q-card class="rounded-[1.5rem] shadow-2xl p-8 max-w-sm w-full text-center relative overflow-hidden bg-white dark:bg-slate-800"> <q-card class="rounded-[1.5rem] shadow-2xl p-8 max-w-sm w-full text-center relative overflow-hidden bg-white dark:bg-slate-800">
<div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-green-400 to-emerald-600"></div> <div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-green-400 to-emerald-600"></div>
<div class="w-16 h-16 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-full flex items-center justify-center text-3xl mx-auto mb-6"></div> <div class="w-16 h-16 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-full flex items-center justify-center text-3xl mx-auto mb-6">
</div>
<h2 class="text-2xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('enrollment.successTitle') }}</h2> <h2 class="text-2xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('enrollment.successTitle') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-8">{{ $t('enrollment.successDesc') }}</p> <p class="text-slate-500 dark:text-slate-400 mb-8">{{ $t('enrollment.successDesc') }}</p>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<q-btn v-if="validCourseId" :to="`/classroom/learning?course_id=${validCourseId}`" unelevated rounded color="primary" class="w-full py-3 text-lg font-bold shadow-lg" :label="$t('enrollment.startNow')" /> <q-btn
<q-btn v-else unelevated rounded color="primary" class="w-full py-3 text-lg font-bold shadow-lg" :label="$t('common.close')" @click="showEnrollModal = false" /> v-if="validCourseId"
<q-btn v-if="validCourseId" flat rounded color="grey-7" class="w-full py-3 font-bold" :label="$t('enrollment.later')" @click="showEnrollModal = false" /> :to="`/classroom/learning?course_id=${validCourseId}`"
unelevated
rounded
color="primary"
class="w-full py-3 text-lg font-bold shadow-lg"
:label="$t('enrollment.startNow')"
/>
<q-btn
v-else
unelevated
rounded
color="primary"
class="w-full py-3 text-lg font-bold shadow-lg"
:label="$t('common.close')"
@click="showEnrollModal = false"
/>
<q-btn
v-if="validCourseId"
flat
rounded
color="grey-7"
class="w-full py-3 font-bold"
:label="$t('enrollment.later')"
@click="showEnrollModal = false"
/>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>
</template> </template>
<style scoped>
/* Custom Font for Signature/Name if desired */
.font-handwriting {
font-family: 'Dancing Script', cursive, serif; /* Fallback */
}
</style>

View file

@ -4,15 +4,13 @@ definePageMeta({
middleware: 'auth' middleware: 'auth'
}) })
const { locale, t } = useI18n() useHead({
title: 'ตั้งค่าบัญชี - e-Learning'
})
const { currentUser, updateUserProfile, changePassword, uploadAvatar, sendVerifyEmail, fetchUserProfile } = useAuth() const { currentUser, updateUserProfile, changePassword, uploadAvatar, sendVerifyEmail, fetchUserProfile } = useAuth()
const { getLocalizedText } = useCourse() const { getLocalizedText } = useCourse()
import { useQuasar } from 'quasar' const { locale, t } = useI18n()
const $q = useQuasar()
useHead({
title: `${t('userMenu.settings')} - e-Learning`
})
@ -59,30 +57,23 @@ const passwordForm = reactive({
confirmPassword: '' confirmPassword: ''
}) })
const showPasswordModal = ref(false)
const showPassword = reactive({
current: false,
new: false,
confirm: false
})
// 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) => { const toggleEdit = (edit: boolean) => {
isEditing.value = edit isEditing.value = edit
} }
// File ( Event ) (Updated to accept File object directly (or Event for view mode compatibility if needed)) // Updated to accept File object directly (or Event for view mode compatibility if needed)
const handleFileUpload = async (fileOrEvent: File | Event) => { const handleFileUpload = async (fileOrEvent: File | Event) => {
let file: File | null = null let file: File | null = null
if (fileOrEvent instanceof File) { if (fileOrEvent instanceof File) {
file = fileOrEvent file = fileOrEvent
} else { } else {
// input change (Fallback for native input change event) // Fallback for native input change event
const target = (fileOrEvent as Event).target as HTMLInputElement const target = (fileOrEvent as Event).target as HTMLInputElement
if (target.files && target.files[0]) { if (target.files && target.files[0]) {
file = target.files[0] file = target.files[0]
@ -104,15 +95,14 @@ const handleFileUpload = async (fileOrEvent: File | Event) => {
if (result.success && result.data?.avatar_url) { if (result.success && result.data?.avatar_url) {
userData.value.photoURL = result.data.avatar_url userData.value.photoURL = result.data.avatar_url
$q.notify({ type: 'positive', message: 'อัปเดตรูปโปรไฟล์สำเร็จ', position: 'top' })
} else { } else {
console.error('Upload failed:', result.error) console.error('Upload failed:', result.error)
$q.notify({ type: 'negative', message: result.error || t('profile.updateError') || 'อัปเดตรูปโปรไฟล์ไม่สำเร็จ', position: 'top' }) alert(result.error || t('profile.updateError'))
} }
} }
} }
// View (Trigger upload for VIEW mode avatar click) // Trigger upload for VIEW mode avatar click
const triggerUpload = () => { const triggerUpload = () => {
fileInput.value?.click() fileInput.value?.click()
} }
@ -141,9 +131,9 @@ const handleUpdateProfile = async () => {
const result = await updateUserProfile(payload) const result = await updateUserProfile(payload)
if (result?.success) { if (result?.success) {
$q.notify({ type: 'positive', message: t('profile.updateSuccess'), position: 'top' }) // success logic
} else { } else {
$q.notify({ type: 'negative', message: result?.error || t('profile.updateError'), position: 'top' }) alert(result?.error || t('profile.updateError'))
} }
isProfileSaving.value = false isProfileSaving.value = false
@ -155,19 +145,19 @@ const handleSendVerifyEmail = async () => {
isSendingVerify.value = false isSendingVerify.value = false
if (result.success) { if (result.success) {
$q.notify({ type: 'positive', message: result.message || t('profile.verifyEmailSuccess') || 'ส่งอีเมลยืนยันสำเร็จ', position: 'top' }) alert(result.message || t('profile.verifyEmailSuccess') || 'ส่งอีเมลยืนยันสำเร็จ')
} else { } else {
if (result.code === 400) { if (result.code === 400) {
$q.notify({ type: 'warning', message: t('profile.emailAlreadyVerified') || 'อีเมลของคุณได้รับการยืนยันแล้ว', position: 'top' }) alert(t('profile.emailAlreadyVerified') || 'อีเมลของคุณได้รับการยืนยันแล้ว')
} else { } else {
$q.notify({ type: 'negative', message: result.error || t('profile.verifyEmailError') || 'ส่งอีเมลไม่สำเร็จ', position: 'top' }) alert(result.error || t('profile.verifyEmailError') || 'ส่งอีเมลไม่สำเร็จ')
} }
} }
} }
const handleUpdatePassword = async () => { const handleUpdatePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) { if (passwordForm.newPassword !== passwordForm.confirmPassword) {
$q.notify({ type: 'negative', message: 'รหัสผ่านใหม่ไม่ตรงกัน', position: 'top' }) alert('รหัสผ่านใหม่ไม่ตรงกัน')
return return
} }
@ -179,19 +169,18 @@ const handleUpdatePassword = async () => {
}) })
if (result.success) { if (result.success) {
$q.notify({ type: 'positive', message: t('profile.passwordSuccess') || 'เปลี่ยนรหัสผ่านสำเร็จ', position: 'top' }) alert(t('profile.passwordSuccess'))
passwordForm.currentPassword = '' passwordForm.currentPassword = ''
passwordForm.newPassword = '' passwordForm.newPassword = ''
passwordForm.confirmPassword = '' passwordForm.confirmPassword = ''
showPasswordModal.value = false
} else { } else {
$q.notify({ type: 'negative', message: result.error || t('profile.passwordError') || 'เปลี่ยนรหัสผ่านไม่สำเร็จ', position: 'top' }) alert(result.error || t('profile.passwordError'))
} }
isPasswordSaving.value = false 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) => { watch(() => currentUser.value, (newUser) => {
if (newUser) { if (newUser) {
userData.value.photoURL = newUser.photoURL || '' userData.value.photoURL = newUser.photoURL || ''
@ -214,215 +203,169 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="page-container bg-[#F8F9FA] dark:bg-[#020617] transition-colors duration-300 min-h-screen"> <div class="page-container bg-[#F8F9FA] dark:bg-[#020617] min-h-screen transition-colors duration-300">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<q-btn
v-if="isHydrated && isEditing"
flat
round
icon="arrow_back"
class="text-slate-600 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-800"
@click="toggleEdit(false)"
/>
<div class="flex items-start gap-4">
<span class="w-1.5 h-10 md:h-12 bg-blue-600 rounded-full shadow-lg shadow-blue-500/50 mt-1 flex-shrink-0"></span>
<div>
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
{{ (isHydrated && isEditing) ? $t('profile.editProfile') : $t('profile.myProfile') }}
</h1>
</div>
</div>
</div>
<div class="min-h-9 flex items-center">
<q-btn
v-if="isHydrated && !isEditing"
unelevated
rounded
color="primary"
class="font-bold shadow-lg shadow-blue-500/20"
icon="edit"
:label="$t('profile.editProfile')"
@click="toggleEdit(true)"
/>
<div
v-else-if="!isHydrated"
class="h-9 w-24 rounded-md bg-slate-200 dark:bg-slate-700 animate-pulse"
/>
</div>
</div>
<div v-if="!isHydrated" class="flex justify-center py-20"> <div v-if="!isHydrated" class="flex justify-center py-20">
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<!-- การตงคาโปรไฟลหล (MAIN PROFILE SETTINGS) --> <div v-else class="max-w-4xl mx-auto pb-20">
<div v-else class="max-w-5xl mx-auto pb-20 fade-in pt-4">
<!-- ตรขอมลโปรไฟล (Profile Card) --> <!-- VIEW MODE: Premium Card with Banner -->
<div class="bg-white dark:!bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm mb-6 overflow-hidden"> <div v-if="!isEditing" class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-slate-700/50 rounded-3xl shadow-xl dark:shadow-none overflow-hidden fade-in min-h-[500px] flex flex-col transition-colors duration-300">
<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.myProfile') }}</h2> <!-- Identity Header (Banner & Avatar) -->
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">{{ $t('profile.publicInfo') }}</p> <div class="relative">
<div class="h-40 bg-gradient-to-r from-blue-700 via-blue-600 to-indigo-700 relative overflow-hidden">
<!-- Abstract Patterns -->
<div class="absolute inset-0 opacity-10">
<div class="absolute -top-10 -right-10 w-64 h-64 rounded-full bg-white blur-3xl"></div>
<div class="absolute -bottom-10 -left-10 w-48 h-48 rounded-full bg-indigo-300 blur-3xl"></div>
</div>
</div> </div>
<div class="p-8"> <div class="px-8 md:px-12 flex flex-col md:flex-row items-center md:items-end gap-8 md:gap-12 -mt-12 pb-8 relative z-10">
<!-- วนอปโหลดรปโปรไฟล --> <div class="relative group flex-shrink-0">
<div class="flex flex-col sm:flex-row items-center sm:items-center gap-6 mb-10 text-center sm:text-left">
<div class="relative cursor-pointer" @click="triggerUpload">
<UserAvatar <UserAvatar
:photo-u-r-l="userData.photoURL" :photo-u-r-l="userData.photoURL"
:first-name="userData.firstName" :first-name="userData.firstName"
:last-name="userData.lastName" :last-name="userData.lastName"
size="100" size="140"
class="rounded-full bg-slate-100 dark:bg-slate-800 object-cover border-4 border-slate-50 dark:border-slate-800" class="border-[6px] border-white dark:border-[#1e293b] shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-800 transition-colors duration-300"
/> />
<div class="absolute bottom-0 right-0 bg-[#3B6BE8] text-white p-1.5 rounded-full border-2 border-white dark:border-slate-900 hover:bg-blue-700 transition flex items-center justify-center">
<q-icon name="edit" size="14px" />
</div> </div>
<input type="file" ref="fileInput" class="hidden" accept="image/jpeg, image/png, image/gif" @change="handleFileUpload" />
<div class="text-center md:text-left pt-4 md:pt-0 flex-grow min-w-0">
<h2 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white mb-2 leading-tight tracking-tight break-words">
{{ userData.firstName }} {{ userData.lastName }}
</h2>
<div class="flex flex-wrap items-center justify-center md:justify-start gap-4">
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-slate-800/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-slate-700">
<q-icon name="alternate_email" size="xs" class="text-blue-500" />
<span class="text-sm">{{ userData.email }}</span>
</div>
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-slate-800/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-slate-700">
<q-icon name="verified_user" size="xs" :class="userData.emailVerifiedAt ? 'text-green-500' : 'text-amber-500'" />
<span class="text-sm">{{ userData.emailVerifiedAt ? $t('profile.emailVerified') : $t('profile.verifyEmail') }}</span>
</div>
</div>
</div> </div>
<div class="flex flex-col items-center sm:items-start">
<button @click="triggerUpload" class="bg-[#3B6BE8] hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg text-sm font-bold transition mb-2 shadow-sm whitespace-nowrap">
<span v-if="isProfileSaving"><q-spinner size="18px" /> {{ $t('profile.uploading') }}</span>
<span v-else>{{ $t('profile.changeAvatar') }}</span>
</button>
<p class="text-slate-500 dark:text-slate-400 text-xs mt-1">{{ $t('profile.avatarHint') }}</p>
</div> </div>
</div> </div>
<!-- ลดอมลฟอร (แบงเป 2 คอลมน) (Form Inputs (2 Column Grid)) --> <!-- View Details Content -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-4"> <div class="p-8 md:p-12 flex-grow">
<div class="md:col-span-2 relative"> <div class="max-w-3xl mx-auto h-full fade-in">
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.prefix') }}</label> <h3 class="text-sm font-black text-slate-700 dark:text-slate-300 uppercase tracking-widest flex items-center gap-2 mb-8">
<select v-model="userData.prefix" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white appearance-none cursor-pointer"> <span class="w-2 h-2 bg-blue-600 rounded-full"></span> {{ $t('profile.accountDetails') }}
<option value="" disabled>{{ $t('profile.selectPrefix') }}</option> </h3>
<option value="นาย">{{ $t('profile.mr') }}</option> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
<option value="นาง">{{ $t('profile.mrs') }}</option> <div class="flex items-center gap-4 group">
<option value="นางสาว">{{ $t('profile.miss') }}</option> <div class="w-12 h-12 rounded-2xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform">
</select> <q-icon name="smartphone" size="24px" />
<div class="pointer-events-none absolute bottom-0 right-0 flex items-center px-4 h-11 text-slate-500">
<q-icon name="arrow_drop_down" size="24px" />
</div>
</div> </div>
<div> <div>
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.firstName') }}-{{ $t('profile.lastName') }}</label> <div class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-0.5">{{ $t('profile.phone') }}</div>
<div class="flex gap-3"> <div class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">{{ userData.phone || '-' }}</div>
<input type="text" v-model="userData.firstName" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white" :placeholder="$t('profile.firstName')" />
<input type="text" v-model="userData.lastName" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white" :placeholder="$t('profile.lastName')" />
</div> </div>
</div> </div>
<div class="flex items-center gap-4 group">
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-900/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 group-hover:scale-110 transition-transform">
<q-icon name="calendar_today" size="24px" />
</div>
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-0.5">{{ $t('profile.joinedAt') }}</div>
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300">{{ $t('profile.email') }}</label> <div class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">{{ formatDate(userData.createdAt) }}</div>
<div v-if="userData.emailVerifiedAt" class="flex items-center gap-1 text-green-500 text-xs font-bold">
<q-icon name="verified_user" size="14px" /> {{ $t('profile.emailVerified') }}
</div>
<button v-else @click="handleSendVerifyEmail" :disabled="isSendingVerify" class="flex items-center gap-1 text-amber-500 hover:text-amber-600 text-xs font-bold transition">
<q-icon name="warning" size="14px" /> <span v-if="isSendingVerify"><q-spinner size="xs" /> {{ $t('profile.verifying') }}</span><span v-else class="underline">{{ $t('profile.verifyNow') }}</span>
</button>
</div>
<input type="email" v-model="userData.email" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none transition text-sm font-medium text-slate-500 dark:text-slate-400" disabled />
</div>
<div>
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.phone') }}</label>
<input type="tel" v-model="userData.phone" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white" placeholder="08x-xxx-xxxx" />
</div>
<div>
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.joinedAt') }}</label>
<input type="text" :value="formatDate(userData.createdAt)" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none transition text-sm font-medium text-slate-500 dark:text-slate-400" disabled />
</div> </div>
</div> </div>
</div> </div>
<!-- มกดยนยนตางๆ (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">
<span v-if="isProfileSaving"><q-spinner size="18px" color="white" class="mr-1" /> {{ $t('profile.saving') }}</span>
<span v-else>{{ $t('common.saveChanges') }}</span>
</button>
</div>
</div>
<!-- การดความปลอดภ (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>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">{{ $t('profile.securitySubtitle') }}</p>
</div>
<div class="px-6 sm:px-8 py-8 sm:py-10">
<div class="p-5 sm:p-6 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800 flex flex-col sm:flex-row items-center sm:items-center justify-between gap-6 text-center sm:text-left">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex items-center justify-center shrink-0">
<q-icon name="key" size="24px" />
</div>
<div>
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] sm:text-[16px]">{{ $t('profile.password') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-xs mt-0.5 sm:mt-1">{{ $t('profile.securitySubtitle') }}</p>
</div>
</div>
<button @click="showPasswordModal = true" class="w-full sm:w-auto bg-[#3B6BE8] hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg text-sm font-bold transition shadow-sm shadow-blue-500/10">
{{ $t('profile.changePasswordBtn') }}
</button>
</div> </div>
</div> </div>
</div> </div>
</div> <!-- EDIT MODE: Tabs and Forms (Clean Layout) -->
<div v-else class="fade-in">
<!-- โมดอลเปลยนรหสผาน (Password Modal) --> <!-- Tab Selector -->
<q-dialog v-model="showPasswordModal"> <div class="flex justify-center mb-8">
<q-card class="w-full max-w-md rounded-2xl p-2 dark:bg-slate-900 shadow-xl"> <div class="bg-white dark:bg-[#1e293b] p-1.5 rounded-2xl flex items-center gap-1 border border-slate-200 dark:border-slate-700 shadow-sm">
<q-form @submit="handleUpdatePassword"> <button
<q-card-section class="flex items-center justify-between pb-2"> @click="activeTab = 'general'"
<div class="text-xl font-bold text-slate-900 dark:text-white">{{ $t('profile.changePasswordBtn') }}</div> class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
<q-btn icon="close" flat round dense v-close-popup class="text-slate-500" /> :class="activeTab === 'general' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 shadow-sm scale-100' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 scale-95 opacity-70'"
</q-card-section>
<q-card-section class="pt-2">
<div class="space-y-1">
<div>
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-1">{{ $t('profile.currentPassword') }}</label>
<q-input
v-model="passwordForm.currentPassword"
:type="showPassword.current ? 'text' : 'password'"
outlined
dense
class="custom-pwd-input"
:rules="[val => !!val || $t('common.required')]"
> >
<template v-slot:append> <q-icon name="person_outline" size="18px" /> {{ $t('profile.generalInfo') }}
<q-icon </button>
:name="showPassword.current ? 'visibility_off' : 'visibility'" <button
class="cursor-pointer text-slate-400" @click="activeTab = 'security'"
@click="showPassword.current = !showPassword.current" class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
/> :class="activeTab === 'security' ? 'bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 shadow-sm scale-100' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 scale-95 opacity-70'"
</template>
</q-input>
</div>
<div>
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-1">{{ $t('profile.newPassword') }}</label>
<q-input
v-model="passwordForm.newPassword"
:type="showPassword.new ? 'text' : 'password'"
outlined
dense
class="custom-pwd-input"
:rules="[
val => !!val || $t('common.required'),
val => val.length >= 6 || $t('profile.newPasswordHint')
]"
> >
<template v-slot:append> <q-icon name="lock_open" size="18px" /> {{ $t('profile.security') }}
<q-icon </button>
:name="showPassword.new ? 'visibility_off' : 'visibility'" </div>
class="cursor-pointer text-slate-400"
@click="showPassword.new = !showPassword.new"
/>
</template>
</q-input>
</div> </div>
<div> <!-- Edit Content -->
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-1">{{ $t('profile.confirmNewPassword') }}</label> <div class="max-w-3xl mx-auto">
<q-input <div v-if="activeTab === 'general'" class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-slate-700/50 rounded-3xl shadow-xl dark:shadow-none p-6 md:p-10">
v-model="passwordForm.confirmPassword" <ProfileEditForm
:type="showPassword.confirm ? 'text' : 'password'" v-model="userData"
outlined :loading="isProfileSaving"
dense :verifying="isSendingVerify"
class="custom-pwd-input" @submit="handleUpdateProfile"
:rules="[ @upload="handleFileUpload"
val => !!val || $t('common.required'), @verify="handleSendVerifyEmail"
val => val === passwordForm.newPassword || $t('common.passwordsDoNotMatch') />
]" </div>
> <div v-else class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-slate-700/50 rounded-3xl shadow-xl dark:shadow-none p-6 md:p-10">
<template v-slot:append> <PasswordChangeForm
<q-icon v-model="passwordForm"
:name="showPassword.confirm ? 'visibility_off' : 'visibility'" :loading="isPasswordSaving"
class="cursor-pointer text-slate-400" @submit="handleUpdatePassword"
@click="showPassword.confirm = !showPassword.confirm"
/> />
</template>
</q-input>
</div> </div>
</div> </div>
</q-card-section> </div>
<q-card-actions align="right" class="pt-2 pb-2 px-4"> </div>
<q-btn flat :label="$t('common.cancel')" color="grey-7" v-close-popup class="font-bold text-[13px]" />
<q-btn type="submit" unelevated color="primary" :label="$t('common.save')" :loading="isPasswordSaving" class="font-bold rounded-lg px-4 text-[13px]" />
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</div> </div>
</template> </template>
@ -432,13 +375,7 @@ onMounted(async () => {
color: white; color: white;
} }
.custom-pwd-input :deep(.q-field__control) { /* Removed card-premium and dark mode overrides as we used utility classes */
border-radius: 8px;
background-color: #f8fafc;
}
.dark .custom-pwd-input :deep(.q-field__control) {
background-color: #1e293b;
}
.fade-in { .fade-in {
animation: fadeIn 0.4s ease-out forwards; animation: fadeIn 0.4s ease-out forwards;

View file

@ -13,29 +13,47 @@ useHead({
title: 'E-Learning System - ระบบการเรียนการสอนออนไลน์' title: 'E-Learning System - ระบบการเรียนการสอนออนไลน์'
}) })
import { CATEGORY_CARDS, WHY_CHOOSE_US } from '@/constants/landing'
const { fetchCategories } = useCategory() const { fetchCategories } = useCategory()
const { fetchCourses, getLocalizedText } = useCourse() const { fetchCourses, getLocalizedText } = useCourse()
const { user } = useAuth() const { user } = useAuth()
const categoryCards = CATEGORY_CARDS const stepOneCards = [
const whyChooseUs = WHY_CHOOSE_US { title: 'AI Foundations', desc: 'เข้าใจพื้นฐาน AI ใช้งานจริงได้ทุกสายงาน', bgClass: 'bg-slate-900', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-slate-900', categorySlug: 'programming' },
{ title: 'Data Analyst', desc: 'เรียนจนทำ Dashboard วิเคราะห์ Data ได้เลย', bgClass: 'bg-amber-500', textClass: 'text-slate-900', arrowClass: 'text-slate-900/40 border-slate-900/10 group-hover:text-amber-500', categorySlug: 'business' },
{ title: 'Front-End Web Developer', desc: 'เขียนเว็บสวย ใช้งานได้จริงตั้งแต่หน้าแรก', bgClass: 'bg-orange-500', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-orange-500', categorySlug: 'programming' },
{ title: 'UX/UI Designer', desc: 'ต่อยอดทำ Portfolio ไม่มีประสบการณ์ก็เรียนได้', bgClass: 'bg-pink-600', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-pink-600', categorySlug: 'design' },
{ title: 'Product Manager', desc: 'เก็บทุกทักษะ ปั้น Product วางแผนแบบมือโปร', bgClass: 'bg-teal-500', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-teal-500', categorySlug: 'business' },
{ title: 'Back-End Developer', desc: 'เข้าใจโครงสร้างระบบและฐานข้อมูลหลังบ้าน', bgClass: 'bg-blue-600', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-blue-600', categorySlug: 'programming' },
{ title: 'Supply Chain & Logistics', desc: 'ใช้ Data วางแผนโลจิสติกส์ได้อย่างมีประสิทธิภาพ', bgClass: 'bg-slate-700', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-slate-700', categorySlug: 'business' }
]
// (Level) const learningStyles = [
const levelModel = ref('ระดับทั้งหมด') {
const levelOptions = ['ระดับทั้งหมด','ระดับเริ่มต้น', 'ระดับกลาง', 'ระดับสูง'] title: 'คอร์สออนไลน์', icon: 'desktop_windows', type: 'ONLINE',
subtitle: 'เรียนได้ทุกที่ ทุกเวลา', desc: 'คัดสรรเนื้อหาคุณภาพจากผู้เชี่ยวชาญ\nพร้อมให้คุณเริ่มต้นเรียนรู้ได้ทันที',
time: 'เข้าถึงได้ตลอดชีพ',
features: ['เนื้อหาครบทุกประเด็นสำคัญ', 'โจทย์ตัวอย่างและแบบฝึกหัด', 'เรียนซ้ำได้ไม่จำกัด', 'ใบเซอร์ทิฟิเคตหลังเรียนจบ'],
iconBg: 'bg-blue-50', iconColor: 'text-blue-600', titleClass: 'text-blue-700',
btnClass: 'bg-indigo-900 text-white hover:bg-indigo-800'
}
]
const promoCategories = [
{ title: 'Data', desc: 'เรียนรู้และฝึกฝนกระบวนการคิดสร้างมูลค่าให้ธุรกิจด้วยข้อมูล', icon: 'analytics' },
{ title: 'Design', desc: 'ออกแบบ Digital Product เพื่อให้ผู้ใช้งานได้รับประสบการณ์ที่ดีที่สุด', icon: 'palette' },
{ title: 'Tech', desc: 'พร้อมเป็นที่ต้องการของตลาดแรงงานด้วยทักษะการเขียนโปรแกรม', icon: 'code' },
{ title: 'Business', desc: 'พลิกโฉมธุรกิจในยุคดิจิทัลด้วยการเข้าถึงลูกค้าในช่องทางและเวลาที่เหมาะสม', icon: 'trending_up' }
]
const categories = ref<any[]>([]) const categories = ref<any[]>([])
const topCourses = ref<any[]>([]) const topCourses = ref<any[]>([])
const selectedCategory = ref('all') const selectedCategory = ref('all')
const isLoading = ref(false) const isLoading = ref(false)
const currentSlide = ref(0) const currentSlide = ref(0)
const courseChunks = computed(() => { const courseChunks = computed(() => {
const chunkSize = 4 const chunkSize = 4
const chunks = [] const chunks = []
if (!topCourses.value || topCourses.value.length === 0) return [] if (!topCourses.value) return []
for (let i = 0; i < topCourses.value.length; i += chunkSize) { for (let i = 0; i < topCourses.value.length; i += chunkSize) {
chunks.push(topCourses.value.slice(i, i + chunkSize)) chunks.push(topCourses.value.slice(i, i + chunkSize))
} }
@ -47,7 +65,7 @@ const loadData = async () => {
try { try {
const [catRes, courseRes] = await Promise.all([ const [catRes, courseRes] = await Promise.all([
fetchCategories(), fetchCategories(),
fetchCourses({ limit: 12, forceRefresh: true }) fetchCourses({ limit: 8, forceRefresh: true })
]) ])
if (catRes.success) categories.value = catRes.data || [] if (catRes.success) categories.value = catRes.data || []
@ -66,7 +84,7 @@ const goBrowse = (slug: string) => {
watch(selectedCategory, async (newVal) => { watch(selectedCategory, async (newVal) => {
isLoading.value = true isLoading.value = true
try { try {
const params: any = { limit: 12 } const params: any = { limit: 8 }
if (newVal !== 'all') { if (newVal !== 'all') {
const category = categories.value.find(c => c.slug === newVal) const category = categories.value.find(c => c.slug === newVal)
if (category) { if (category) {
@ -92,41 +110,41 @@ onMounted(() => {
<template> <template>
<div class="landing-page bg-white min-h-screen"> <div class="landing-page bg-white min-h-screen">
<!-- Section 1: Hero Section --> <!-- Hero Section -->
<section class="container mx-auto py-24 md:py-24 lg:py-28 px-6 lg:px-12 pb-16"> <header class="relative pt-32 pb-16 md:pt-40 md:pb-20 overflow-hidden bg-white">
<div class="flex flex-col lg:flex-row items-center gap-10 lg:gap-10 justify-between animate-fade-in"> <!-- Decorative Background -->
<!-- านซาย: อความและปมกด (Left Content) --> <div class="absolute top-0 right-0 w-[45%] h-[105%] bg-blue-50/50 rounded-bl-[12rem] -z-10 animate-fade-in"/>
<div class="flex flex-col items-start gap-6 flex-1 max-w-2xl ">
<!-- วขอหล (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) --> <div class="container mx-auto px-6 md:px-12 grid grid-cols-1 md:grid-cols-2 items-center gap-16">
<p class="text-slate-500 text-lg sm:text-xl leading-relaxed slide-up" style="animation-delay: 0.3s;"> <div class="hero-left slide-up">
ดประกายความรของค และเรมตนอปสกลกบผเชยวชาญ <div class="flex items-center gap-3 mb-8 text-blue-600">
ในอตสาหกรรมทความรรอบดานหลากหลายในหลายสาขา <q-icon name="stars" size="28px" />
เรยนไดกท กเวลา <span class="text-sm font-black tracking-widest uppercase">E-Learning Platform</span>
</div>
<h1 class="text-4xl md:text-5xl lg:text-7xl font-bold text-slate-900 leading-[1.2] mb-8 tracking-normal">
คอรสเรยนออนไลน<br><span class="text-blue-600">เพมทกษะ</span>คด
</h1>
<p class="text-slate-500 text-lg md:text-xl font-medium mb-12 leading-relaxed max-w-xl slide-up" style="animation-delay: 0.1s;">
แหลงรวมคอรสออนไลนณภาพสงทจะชวยอปสกลใหณทำงานเกงข ฒนาทกษะทตลาดตองการ พรอมใหณกาวไปขางหนาไดอยางมนใจ!
</p> </p>
<!-- มกดตางๆ (Buttons) --> <!-- Search Bar Pill -->
<div class=" w-full flex flex-col sm:flex-row items-center gap-4 pt-5 slide-up" style="animation-delay: 0.4s;"> <div class="flex flex-col sm:flex-row gap-4 mb-10 slide-up" style="animation-delay: 0.2s;">
<q-btn <q-btn
unelevated unelevated
rounded rounded
color="blue-600" color="primary"
label="ดูคอร์สเรียน" label="ดูคอร์สเรียนทั้งหมด"
class="px-10 py-4 w-full sm:w-auto rounded-3xl font-semibold text-white text-lg shadow-xl shadow-blue-600/20 transition-transform" class="px-10 h-16 font-black text-white text-xl shadow-xl shadow-blue-600/20 hover:scale-105 transition-transform"
no-caps no-caps
to="/browse" to="/browse"
/> />
<q-btn <q-btn
outline outline
rounded rounded
color="primary"
label="สมัครสมาชิกฟรี" label="สมัครสมาชิกฟรี"
color="grey-8" class="px-10 h-16 font-black text-xl border-2 hover:bg-blue-50"
class="px-10 py-4 w-full sm:w-auto btn-user rounded-3xl font-semibold text-lg hover:bg-blue-50 "
no-caps no-caps
to="/auth/register" to="/auth/register"
v-if="!user" v-if="!user"
@ -134,171 +152,192 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- านขวา: ปภาพฮโร (Right - Hero Image) --> <!-- Hero Visual Showcase -->
<div class="flex-1 w-full max-w-lg md:max-w-md lg:max-w-xl pl-0 py-10"> <div class="hero-right flex justify-center md:justify-end items-center slide-up" style="animation-delay: 0.2s;">
<div class="relative rounded-2xl overflow-hidden shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] aspect-square"> <div class="relative w-full max-w-xl">
<!-- Main Illustration -->
<div class="relative z-10 animate-float">
<img <img
src="https://api.builder.io/api/v1/image/assets/TEMP/11ba9b46c799fac950967377f8158fa942c1a6b8?width=1184" src="/img/elearning.png"
alt="Students collaborating" alt="E-Learning Illustration"
class="w-full h-full object-cover" class="w-full h-auto drop-shadow-2xl"
/> />
<div class="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" /> </div>
<!-- วนทบซอนสำหรบทำโมเดลการดคอร (Course Card Overlay) --> <!-- Decorative shapes behind the image -->
<!-- <div class="absolute bottom-5 left-5 right-5"> <div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[120%] h-[120%] bg-blue-50/50 rounded-full blur-3xl -z-10" />
<div class="bg-white/85 backdrop-blur-sm border border-white/20 rounded-3xl px-6 py-5"> <div class="absolute -top-10 -left-10 w-32 h-32 bg-amber-100 rounded-[3rem] -z-10 animate-pulse" />
<div class="flex items-center gap-4"> <div class="absolute -bottom-10 -right-10 w-48 h-48 bg-blue-100 rounded-full -z-10 animate-pulse" style="animation-delay: -2s;" />
<div class="flex-shrink-0 w-9 h-9 flex items-center justify-center rounded-2xl bg-blue-600/20">
<q-icon name="o_play_circle" size="25px" class="text-blue-600" />
</div>
<div class="flex flex-col">
<span class="text-slate-900 font-bold text-sm leading-5">
เรยนรการออกแบบ UI/UX
</span>
<span class="text-slate-500 text-xs leading-4 mt-0.5">
คอรสวโอ 12 บทเรยน
</span>
</div> </div>
</div> </div>
</div> </div>
</div> --> </header>
</div>
</div>
</div>
</section>
<!-- Section 2: ทำไมตองเลอกแพลตฟอรมของเรา --> <section class="pt-16 pb-12 md:pt-24 md:pb-20 bg-white">
<section class="pt-20 pb-14 bg-white relative flex-col">
<div class="container mx-auto px-6 lg:px-12"> <div class="container mx-auto px-6 lg:px-12">
<!-- วขอหล (Heading) -->
<div class="text-center mb-16 slide-up"> <div class="text-center mb-16 slide-up">
<h2 class="text-4xl md:text-[2.4rem] font-bold text-slate-900 mb-6"> <h2 class="text-3xl md:text-5xl font-bold text-slate-900 mb-6 px-4">
ทำไมตองเลอกแพลตฟอรมของเรา? เพราะ าวแรก ของการพฒนาตวเอง าทายเสมอ
</h2> </h2>
<p class="text-slate-500 text-base font-normal md:text-xl max-w-3xl mx-auto leading-relaxed"> <p class="text-slate-500 text-lg md:text-xl font-medium max-w-3xl mx-auto leading-relaxed">
เราเครองมอและความเชยวชาญทจะชวยใหณประสบความสำเรจในการเปลยนสายอาชพและการสรางทกษะระดบมออาช เรางตงใจออกแบบบทเรยนให <span class="text-blue-600 font-bold">เขาใจงาย</span> และ <span class="text-blue-600 font-bold">นำไปใชไดจร</span> เพอใหกกาวของค นคงและไปถงเปาหมายไดสำเร
</p> </p>
</div> </div>
<!-- โซนแนะนำแบบการดแนวนอน (Horizontal Cards) --> <!-- Grid Container (Bento Layout) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div class="relative">
<div v-for="(item, i) in whyChooseUs" :key="i" <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
class="slide-up p-10 rounded-2xl bg-[#F8FAFC] border border-[#f1f2f9] hover:border-[#2463eb61] hover:bg-white transition-all duration-500 group" <div v-for="(card, i) in stepOneCards" :key="i"
:style="`animation-delay: ${i * 0.1}s`" class="group cursor-pointer rounded-3xl p-6 flex flex-col justify-between transition-all hover:-translate-y-1 shadow-lg hover:shadow-2xl overflow-hidden relative"
:class="[
card.bgClass,
i === 0 ? 'lg:row-span-2 min-h-[380px]' : 'min-h-[220px]'
]"
@click="goBrowse(card.categorySlug)"
> >
<div class="w-14 h-14 rounded-full bg-[#E3EBFA] flex items-center justify-center mb-5 transition-transform group-hover:scale-110 duration-500"> <!-- Background Accent -->
<q-icon :name="item.icon" size="28px" class="text-blue-600" /> <div class="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/2" />
<div>
<span class="text-[10px] font-bold uppercase tracking-[0.15em] opacity-80 mb-3 block" :class="card.textClass === 'text-white' ? 'text-white/80' : 'text-slate-900/60'">าวแรกของ</span>
<h3 class="text-2xl font-bold leading-tight tracking-tight mb-2" :class="card.textClass">{{ card.title }}</h3>
</div>
<div class="space-y-4 relative z-10">
<p class="text-sm font-medium leading-relaxed opacity-90" :class="card.textClass">{{ card.desc }}</p>
<div class="flex justify-end">
<div class="w-10 h-10 rounded-full border border-white/20 flex items-center justify-center transition-all bg-white/10 group-hover:bg-white/20 group-hover:scale-105 backdrop-blur-sm"
:class="[i === 0 ? 'w-12 h-12' : '']">
<q-icon name="arrow_forward" :size="i === 0 ? '24px' : '20px'" :class="card.textClass" />
</div>
</div>
</div>
</div> </div>
<h3 class="text-[1.3rem] font-bold text-slate-900 group-hover:text-blue-600 transition-colors">
{{ item.title }}
</h3>
<p class="text-slate-500 text-lg leading-relaxed ">
{{ item.desc }}
</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- Section 3: เลอกเรยนตามเรองทณสนใจ --> <!-- Section 2: "Value Proposition" - Why Online Learning here? -->
<section class="py-20 md:py-24 bg-white"> <section class="pt-12 pb-12 md:pt-20 md:pb-20 bg-white relative overflow-hidden">
<!-- Decorative background blur -->
<div class="absolute top-1/2 left-0 -translate-y-1/2 w-96 h-96 bg-blue-100/50 rounded-full blur-[100px] -z-10" />
<div class="container mx-auto px-6 lg:px-12"> <div class="container mx-auto px-6 lg:px-12">
<!-- วข (Heading) --> <div class="grid grid-cols-1 lg:grid-cols-2 gap-20 items-center">
<div class="mb-12 slide-up"> <!-- Left side: Visual representation -->
<h2 class="text-[1.4rem] text-3xl md:text-4xl font-semibold text-slate-900 px-4"> <div class="relative slide-up">
เลอกเรยนตามเรองทณสนใจ <div class="relative z-10 bg-gradient-to-br from-blue-600 to-indigo-700 rounded-[4rem] p-12 md:p-20 shadow-3xl overflow-hidden group">
</h2> <!-- Animated background shapes -->
</div> <div class="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2 group-hover:scale-110 transition-transform duration-1000" />
<div class="absolute bottom-0 left-0 w-48 h-48 bg-amber-400/20 rounded-full translate-y-1/2 -translate-x-1/2" />
<!-- โซนหมวดหมแบบการดแนวนอน (Horizontal Cards) --> <div class="relative z-20 flex flex-col items-center text-center text-white">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 justify-center gap-6 px-4"> <div class="w-24 h-24 md:w-32 md:h-32 bg-white/20 backdrop-blur-md rounded-[2.5rem] flex items-center justify-center mb-8 shadow-inner">
<div v-for="(card, i) in categoryCards" :key="i" <q-icon name="laptop_mac" size="64px" class="text-white" />
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) -->
<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> </div>
<h3 class="text-3xl md:text-5xl font-black leading-[1.2] md:leading-[1.15] tracking-tight mb-0 pt-1 overflow-visible">
<!-- เนอหาขอความ (Content) --> คอรสออนไลน<br class="hidden md:block" />
<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 }}
</h3> </h3>
<p class="text-slate-600 text-xs md:text-sm leading-relaxed opacity-70"> <p class="mt-5 text-blue-100/90 text-base md:text-lg font-medium leading-relaxed max-w-md">
{{ card.desc }} เรยนรกษะใหมจากผเชยวชาญตวจร พรอมเนอหาทเขมขนและใชงานไดจร
</p>
</div>
</div>
<!-- Floating Stats pill -->
<div class="absolute -bottom-10 -right-6 md:right-10 z-30 bg-white p-6 rounded-3xl shadow-2xl animate-float">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-amber-50 flex items-center justify-center text-amber-600">
<q-icon name="query_builder" size="28px" />
</div>
<div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-tighter">Access Status</div>
<div class="text-xl font-black text-slate-900">เขาถงไดตลอดช</div>
</div>
</div>
</div>
</div>
<!-- Right side: Content & Benefits -->
<div class="slide-up" style="animation-delay: 0.2s;">
<div class="mb-12">
<span class="inline-flex items-center px-5 py-2 rounded-full bg-blue-50 text-blue-600 text-xs md:text-sm font-extrabold uppercase tracking-widest mb-5 border border-blue-100">
Premium Learning Experience
</span>
<h2 class="text-4xl md:text-6xl font-bold text-slate-900 leading-[1.2] md:leading-[1.2] tracking-tight mb-0 pt-1 overflow-visible">
าวขามทกขดจำก<br />
วยการเรยนร <span class="text-blue-600">สระ</span>
</h2>
<p class="mt-6 text-slate-500 text-lg md:text-xl font-medium leading-relaxed max-w-2xl">
เราคดสรรและคราฟตกคอรสเรยนเพอใหนใจวาคณจะไดบประสบการณการเรยนร ไมาจะอยไหนหรอเวลาใดกตาม
</p> </p>
</div> </div>
<!-- กศรชขวา (Arrow) --> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
<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"> <div v-for="(feature, f) in learningStyles[0].features" :key="f"
<q-icon name="chevron_right" size="24px" /> class="flex items-center gap-4 p-5 rounded-3xl bg-slate-50 border border-slate-100 group hover:border-blue-200 hover:bg-white hover:shadow-xl transition-all duration-300"
>
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-white flex items-center justify-center text-green-500 shadow-sm group-hover:bg-green-500 group-hover:text-white transition-colors">
<q-icon name="check" size="20px" />
</div> </div>
<span class="text-base font-black text-slate-700 leading-snug">{{ feature }}</span>
</div>
</div>
<q-btn
unelevated
rounded
color="primary"
label="เริ่มต้นบทเรียนแรกของคุณ"
class="px-12 h-20 font-black text-xl md:text-2xl transition-all shadow-xl hover:shadow-2xl hover:-translate-y-1"
no-caps
:to="user ? '/browse' : '/auth/login'"
/>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- Section 4: "คอร์สออนไลน์" --> <!-- Section 4: "คอร์สออนไลน์" -->
<section class="py-12 md:py-24 bg-slate-50"> <section class="pt-12 pb-24 md:pt-20 md:pb-40 bg-slate-50/50">
<div class="container mx-auto px-6 lg:px-12"> <div class="container mx-auto px-6 lg:px-12">
<!-- วขอหลกและลงกเพมเต (Heading) --> <div class="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 gap-8">
<div class="flex flex-col md:flex-row items-start md:items-end justify-between mb-5 gap-8">
<div class="slide-up"> <div class="slide-up">
<h2 class="text-4xl md:text-[2.4rem] font-bold text-slate-900 mb-4">คอรสออนไลน</h2> <h2 class="text-3xl md:text-5xl font-bold text-slate-900 mb-4">คอรสออนไลน</h2>
<p class="text-slate-500 font-bold text-lg">เรมตนเรยนรกษะใหมวยคอรสคณภาพจากผเชยวชาญ</p>
</div> </div>
<NuxtLink to="/browse" class="flex items-center py-3 text-lg rounded-full font-bold "> <NuxtLink to="/browse" class="flex items-center gap-3 px-8 py-3 rounded-full border-2 border-blue-600 text-blue-700 font-bold hover:bg-blue-600 hover:text-white transition-all slide-up">
<span class="text-blue-600 hover:text-blue-500 ">คอรสทงหมด</span> คอรสออนไลนงหมด <q-icon name="arrow_forward" size="20px" />
<q-icon name="arrow_forward" size="15px" class="text-blue-600 ml-2" />
</NuxtLink> </NuxtLink>
</div> </div>
<!-- แถวตวกรองคอร (Filters Row) --> <!-- Filter Tabs / Pills -->
<div class="flex items-center gap-2 mb-8 overflow-x-auto no-scrollbar slide-up justify-between"> <div class="flex items-center gap-4 mb-8 overflow-x-auto pb-6 no-scrollbar slide-up">
<!-- วกรองหมวดหมคอร (Category Filters) -->
<div class="flex items-center gap-2">
<button <button
class="py-2 px-5 rounded-full font-medium text-lg transition-all whitespace-nowrap border-2" class="px-8 py-3 rounded-full font-black text-base transition-all whitespace-nowrap border-2"
:class="selectedCategory === 'all' ? 'bg-blue-600 text-white border-blue-600 font-semibold' : 'bg-white border-slate-100 text-slate-700 hover:border-slate-300'" :class="selectedCategory === 'all' ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
@click="selectedCategory = 'all'" @click="selectedCategory = 'all'"
> >
<q-icon name="o_check_circle" size="20px" class="mr-1" />
งหมด งหมด
</button> </button>
<button <button
v-for="category in categories" v-for="category in categories"
:key="category.id" :key="category.id"
class="py-2 px-5 rounded-full font-medium text-lg transition-all whitespace-nowrap border-[1.5px]" class="px-8 py-3 rounded-full font-black text-base transition-all whitespace-nowrap border-2"
:class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 font-semibold' : 'bg-white border-slate-200 text-slate-700 hover:border-slate-300'" :class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
@click="selectedCategory = category.slug" @click="selectedCategory = category.slug"
> >
<q-icon :name="category.icon || 'o_label'" size="20px" class="mr-1" />
{{ getLocalizedText(category.name) }} {{ getLocalizedText(category.name) }}
</button> </button>
</div> </div>
<!-- Level Dropdown --> <!-- Courses Carousel -->
<!-- <div class="flex items-center gap-2 font-medium"> <div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
<q-select <div v-for="i in 4" :key="i" class="bg-white rounded-[3rem] h-[480px] animate-pulse" />
borderless
v-model="levelModel"
:options="levelOptions"
dropdown-icon="o_keyboard_arrow_down"
class="text-lg"
popup-content-class="rounded-lg text-lg shadow-sm text-slate-700"
>
<template v-slot:before>
<div class="text-slate-700 text-lg">ระดบความยาก:</div>
</template>
</q-select>
</div> -->
</div>
<!-- ระบบเลอนสไลดคอร (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> </div>
<div v-else class="relative group/carousel slide-up"> <div v-else class="relative group/carousel slide-up">
@ -317,68 +356,19 @@ onMounted(() => {
v-for="(chunk, pageIndex) in courseChunks" v-for="(chunk, pageIndex) in courseChunks"
:key="pageIndex" :key="pageIndex"
:name="pageIndex" :name="pageIndex"
class="p-0" class="p-4"
> >
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
<div <CourseCard
v-for="course in chunk" v-for="course in chunk"
:key="course.id" :key="course.id"
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" v-bind="{ ...course, image: course.thumbnail_url }"
@click="navigateTo(`/course/${course.id}`)"
>
<!-- ปภาพหนาปกคอร (Image) -->
<div class="relative flex-shrink-0">
<img
v-if="course.thumbnail_url"
:src="course.thumbnail_url"
:alt="getLocalizedText(course.title)"
class="w-full h-[150px] sm:h-[180px] lg:h-[200px] object-cover"
/> />
<div v-else class="w-full h-[150px] sm:h-[180px] lg:h-[200px] bg-slate-100 flex items-center justify-center">
<q-icon name="o_image" size="40px" class="text-slate-300" />
</div>
</div>
<!-- เนอหาของคอร (Content) -->
<div class="flex flex-col flex-1 p-6">
<!-- อคอร (Title) -->
<h3 class="text-[#0F172A] font-semibold text-lg leading-snug mb-2 line-clamp-2">
{{ getLocalizedText(course.title) }}
</h3>
<!-- รายละเอยดแบบย (Description) -->
<p class="text-slate-500 text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
{{ getLocalizedText(course.description) }}
</p>
<!-- วนแถบราคาและปมกด (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">
{{ course.price.toLocaleString() }}.-
</span>
<span v-else class="text-green-600 font-bold text-xl">
ฟร
</span>
</div>
<button class="flex items-center gap-2 px-4 py-2 rounded-full bg-[#2463EB]/10 hover:bg-[#2463EB]/20 transition-colors">
<q-icon name="o_remove_red_eye" size="18px" class="text-[#2463EB]" />
<span class="text-[#2463EB] font-medium text-sm">
รายละเอยด
</span>
</button>
</div>
</div>
</div>
</div> </div>
</q-carousel-slide> </q-carousel-slide>
</q-carousel> </q-carousel>
<!-- ระบบนำทางสไลดแบบกำหนดเอง (Custom Carousel Navigation) --> <!-- Custom Carousel Navigation -->
<button <button
v-if="courseChunks.length > 1" 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" 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"
@ -400,55 +390,11 @@ onMounted(() => {
</div> </div>
</div> </div>
</section> </section>
<!-- Section 5: "พร้อมเริ่มต้นการเรียนรู้แล้วหรือยัง" -->
<section class="py-16 md:py-24 bg-white">
<div class="max-w-7xl mx-auto px-6 lg:px-12">
<div class="bg-blue-600 rounded-3xl px-8 py-20 md:px-18 text-center relative overflow-hidden">
<div class="gradient-background">
<div class="gradient-sphere sphere-1"></div>
<div class="gradient-sphere sphere-2"></div>
<div class="gradient-sphere sphere-3"></div>
</div>
<div class="grid-overlay"></div>
<div class="relative z-10">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">
พรอมเรมตนการเรยนรแลวหรอย?
</h2>
<p class="text-blue-100 text-lg mb-8 max-w-xl mx-auto">
ปสกลและรบทกษะทณตองการเพอกาวหนาในระดบมออาช
เปดประสบการณการเรยนรปแบบใหม สมครเลยวนนเพอเรมตนเขาสบทเรยน
</p>
<div class="flex flex-wrap justify-center gap-4">
<q-btn
unelevated
rounded
class="px-8 py-4 bg-white font-bold rounded-3xl hover:bg-slate-50 transition-colors"
no-caps
size="18px"
to="/browse"
>
<div class="text-blue-600">สำรวจคอรสเรยน</div>
</q-btn>
<q-btn
outline
rounded
label="สมัครฟรีวันนี้"
color="white"
class="px-8 py-4 font-bold rounded-3xl hover:bg-white/10 transition-colors"
no-caps
size="18px"
to="/auth/register"
v-if="!user"
/>
</div>
</div>
</div>
</div>
</section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.landing-page { .landing-page {
font-family: var(--font-main); font-family: var(--font-main);
@ -473,6 +419,15 @@ onMounted(() => {
opacity: 0; opacity: 0;
} }
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0); }
50% { transform: translateY(-20px) rotate(5deg); }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
@ -482,80 +437,26 @@ onMounted(() => {
animation: fade-in 1s ease-out forwards; animation: fade-in 1s ease-out forwards;
} }
/* Hero Right Hover State */ /* Typography Overrides */
.hero-right:hover .relative { h1, h2, h3 {
transform: translateY(-10px); letter-spacing: normal;
transition: transform 0.5s ease;
} }
.gradient-background { /* Hover effects */
position: absolute; .hero-right:hover .animate-float {
top: 0; animation-play-state: paused;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
overflow: hidden;
} }
.gradient-sphere { /* Responsive Grid Adjustments */
position: absolute; @media (max-width: 1200px) {
border-radius: 50%; .career-cards-grid {
filter: blur(60px); grid-template-columns: repeat(4, 1fr);
}
} }
.sphere-1 { @media (max-width: 768px) {
width: 30vw; .career-cards-grid {
height: 30vw; grid-template-columns: repeat(2, 1fr);
background: linear-gradient(40deg, rgba(255, 255, 255, 0.41), rgba(255, 255, 255, 0.164));
top: 10%;
left: -30%;
animation: float-1 15s ease-in-out infinite alternate;
} }
.sphere-2 {
width: 45vw;
height: 45vw;
background: linear-gradient(240deg, rgba(16, 33, 121, 0.245), rgba(155, 169, 239, 0.263));
bottom: -20%;
right: -35%;
animation: float-2 18s ease-in-out infinite alternate;
}
.sphere-3 {
width: 30vw;
height: 30vw;
background: linear-gradient(120deg, rgba(133, 89, 255, 0.5), rgba(98, 216, 249, 0.3));
top: 60%;
left: 20%;
animation: float-3 20s ease-in-out infinite alternate;
}
@keyframes float-1 {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(10%, 10%) scale(1.1); }
}
@keyframes float-2 {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(-10%, -5%) scale(1.15); }
}
@keyframes float-3 {
0% { transform: translate(0, 0) scale(1); opacity: 0.3; }
100% { transform: translate(-5%, 10%) scale(1.05); opacity: 0.6; }
}
.grid-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: 40px 40px;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
z-index: 2;
} }
</style> </style>

View file

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

View file

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file reset-password.vue * @file reset-password.vue
* @description หนาตงรหสผานใหม (Reset Password Page. * @description Reset Password Page.
* อนญาตใหใชงรหสผานใหมหลงจากยนยนลงกเมล) * Allows user to set a new password after verifying their email link (simulated).
*/ */
definePageMeta({ definePageMeta({
@ -44,9 +44,9 @@ const handlePasswordInput = (field: keyof typeof resetForm, val: string) => {
resetForm[field] = val resetForm[field] = val
if (/[\u0E00-\u0E7F]/.test(val)) { if (/[\u0E00-\u0E7F]/.test(val)) {
if (field === 'password') errors.value.password = 'ห้ามใส่ภาษาไทย' if (field === 'password') errors.value.password = 'ห้ามใส่ภาษาไทย'
// confirmPassword (We don't necessarily need to flag confirmPassword individually if it just needs to match, but let's be consistent if we want) // We don't necessarily need to flag confirmPassword individually if it just needs to match, but let's be consistent if we want
} else { } else {
// "" (Clear error if it was "Thai characters") // Clear error if it was "Thai characters"
if (field === 'password' && errors.value.password === 'ห้ามใส่ภาษาไทย') { if (field === 'password' && errors.value.password === 'ห้ามใส่ภาษาไทย') {
clearFieldError('password') clearFieldError('password')
} }
@ -63,7 +63,7 @@ onMounted(() => {
const resetPassword = async () => { const resetPassword = async () => {
if (!validate(resetForm, resetRules)) return if (!validate(resetForm, resetRules)) return
// URL query (Extract token from query) // Extract token from query
const token = route.query.token as string const token = route.query.token as string
if (!token) { if (!token) {
@ -92,7 +92,7 @@ const resetPassword = async () => {
<template> <template>
<div class="relative min-h-screen w-full flex items-center justify-center p-4 overflow-hidden bg-slate-50 transition-colors"> <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="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> <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> </div>
<!-- ========================================== <!-- ==========================================
การดตงรหสผานใหม (RESET PASSWORD CARD) RESET PASSWORD CARD
========================================== --> ========================================== -->
<div class="w-full max-w-[460px] relative z-10 slide-up"> <div class="w-full max-w-[460px] relative z-10 slide-up">
<!-- วข / โลโก (Header / Logo) --> <!-- Header / Logo -->
<div class="text-center mb-8"> <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"> <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> <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"> <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"> <form @submit.prevent="resetPassword" class="flex flex-col gap-6">
<!-- รหสผานใหม (New Password) --> <!-- New Password -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหสผานใหม <span class="text-red-500">*</span></label> <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"> <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> <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> </div>
<!-- นยนรหสผานใหม (Confirm Password) --> <!-- Confirm Password -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">นยนรหสผานใหม <span class="text-red-500">*</span></label> <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"> <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> <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> </div>
<!-- มยนย (Submit Button) --> <!-- Submit Button -->
<button <button
type="submit" type="submit"
:disabled="isLoading" :disabled="isLoading"
@ -183,7 +183,7 @@ const resetPassword = async () => {
</form> </form>
</div> </div>
<!-- งกอนกล (Back Link) --> <!-- Back Link -->
<div class="mt-8 text-center text-slate-500"> <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"> <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> กลบไปหนาเขาสระบบ <span class="group-hover:-translate-x-1 transition-transform"></span> กลบไปหนาเขาสระบบ

View file

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file verify-email.vue * @file verify-email.vue
* @description หนาสำหรบกระบวนการยนยนอเมล (Page for handling email verification process. * @description Page for handling email verification process.
* แสดงสถานะกำลงโหลดระหวางประมวลผลโทเคน จากนนแสดงขอความสำเรจหรอขอผดพลาด) * Displays loading state while processing token, then shows success or error message.
*/ */
definePageMeta({ definePageMeta({
layout: 'auth' layout: 'default'
}) })
const route = useRoute() const route = useRoute()
@ -28,7 +28,7 @@ onMounted(async () => {
return return
} }
// API (Call verify API) // Call verify API
const result = await verifyEmail(token) const result = await verifyEmail(token)
isLoading.value = false isLoading.value = false
@ -39,7 +39,7 @@ onMounted(async () => {
isSuccess.value = false isSuccess.value = false
if (result.code === 400) { if (result.code === 400) {
errorMessage.value = t('profile.emailAlreadyVerified') 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 isSuccess.value = true
} else if (result.code === 401) { } else if (result.code === 401) {
errorMessage.value = t('auth.tokenExpired') 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="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"> <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"> <div v-if="isLoading" class="flex flex-col items-center justify-center py-8">
<q-spinner-dots size="4rem" color="primary" /> <q-spinner-dots size="4rem" color="primary" />
<h2 class="mt-6 text-xl font-bold text-slate-900 dark:text-white animate-pulse"> <h2 class="mt-6 text-xl font-bold text-slate-900 dark:text-white animate-pulse">
@ -66,10 +66,10 @@ const navigateToHome = () => {
</h2> </h2>
</div> </div>
<!-- สถานะสำเร (Success State) --> <!-- Success State -->
<div v-else-if="isSuccess" class="flex flex-col items-center animate-bounce-in"> <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"> <div class="w-24 h-24 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-6">
<q-icon name="check" class="text-5xl text-white font-black" /> <q-icon name="check_circle" class="text-6xl text-green-500" />
</div> </div>
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-2"> <h2 class="text-3xl font-black text-slate-900 dark:text-white mb-2">
@ -89,7 +89,7 @@ const navigateToHome = () => {
/> />
</div> </div>
<!-- สถานะขอผดพลาด (Error State) --> <!-- Error State -->
<div v-else class="flex flex-col items-center animate-shake"> <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"> <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" /> <q-icon name="error" class="text-6xl text-red-500" />
@ -126,15 +126,11 @@ const navigateToHome = () => {
} }
.auth-card { .auth-card {
background-color: white; @apply bg-white border-slate-100 shadow-xl;
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; border-width: 1px;
} }
:global(.dark) .auth-card { .dark .auth-card {
background-color: #1e293b; @apply bg-[#1e293b] border-white/5 shadow-none;
border-color: rgba(255, 255, 255, 0.05);
box-shadow: none;
} }
@keyframes bounceIn { @keyframes bounceIn {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

View file

@ -1,41 +0,0 @@
/**
* @file auth.ts
* @description Type definitions for authentication and user profiles.
*/
export interface User {
id: number
username: string
email: string
email_verified_at?: string | null
created_at?: string
updated_at?: string
role: {
code: string // เช่น 'STUDENT', 'INSTRUCTOR', 'ADMIN'
name: { th: string; en: string }
}
profile?: {
prefix: { th: string; en: string }
first_name: string
last_name: string
phone: string | null
avatar_url: string | null
}
}
export interface LoginResponse {
token: string
refreshToken: string
user: User
profile: User['profile']
}
export interface RegisterPayload {
username: string
email: string
password: string
first_name: string
last_name: string
prefix: { th: string; en: string }
phone: string
}

View file

@ -1,142 +0,0 @@
/**
* @file course.ts
* @description Type definitions for courses, enrollments, quizzes, and certificates.
*/
export interface Course {
id: number
title: string | { th: string; en: string }
slug: string
description: string | { th: string; en: string }
thumbnail_url: string
price: string
is_free: boolean
original_price?: string
have_certificate: boolean
status: string
category_id: number
created_at?: string
updated_at?: string
created_by?: number
updated_by?: number
approved_at?: string
approved_by?: number
rejection_reason?: string
enrolled?: boolean
total_lessons?: number
rating?: string
lessons?: number | string
levelType?: 'neutral' | 'warning' | 'success'
chapters?: {
id: number
title: string | { th: string; en: string }
lessons: {
id: number
title: string | { th: string; en: string }
duration_minutes: number
video_url?: string
}[]
}[]
creator?: {
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
avatar_url: string
}
}
instructors?: {
user_id: number
is_primary: boolean
user: {
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
avatar_url: string
}
}
}[]
}
export interface CourseResponse {
code: number
message: string
data: Course[]
total: number
page?: number
limit?: number
totalPages?: number
}
export interface SingleCourseResponse {
code: number
message: string
data: Course
}
export interface EnrolledCourse {
id: number
course_id: number
course: Course
status: 'ENROLLED' | 'IN_PROGRESS' | 'COMPLETED' | 'DROPPED'
progress_percentage: number
enrolled_at: string
started_at?: string
completed_at?: string
last_accessed_at?: string
}
export interface EnrolledCourseResponse {
code: number
message: string
data: EnrolledCourse[]
total: number
page: number
limit: number
}
export interface QuizAnswerSubmission {
question_id: number
choice_id: number
}
export interface QuizSubmitRequest {
answers: QuizAnswerSubmission[]
}
export interface QuizResult {
answers_review: {
score: number
is_correct: boolean
correct_choice_id: number
selected_choice_id: number
question_id: number
}[]
completed_at: string
started_at: string
attempt_number: number
passing_score: number
is_passed: boolean
correct_answers: number
total_questions: number
total_score: number
score: number
quiz_id: number
attempt_id: number
}
export interface Certificate {
certificate_id: number
course_id: number
course_title: {
en: string
th: string
}
issued_at: string
download_url: string
}

View file

@ -1,2 +0,0 @@
export * from './auth'
export * from './course'

View file

@ -0,0 +1,71 @@
วันที่บันทึกปฏิบัติงาน \*
18/02/2026
องค์ความรู้ที่ได้รับ \*
- การใช้งาน Nuxt 3 Routing (useRoute, useRouter) ในการจัดการ Query Parameters
- การเชื่อมโยง State กับ URL เพื่อสร้าง Deep Linking ให้แชร์ลิงก์หมวดหมู่ได้
- การใช้งาน Vue 3 Composition API (Script Setup) แทน Options API เพื่อความเป็นระเบียบและลดความซับซ้อน
- การตกแต่ง UI ด้วย Tailwind CSS ขั้นสูง (Gradients, Glow Effects, Backdrop Filters) เพื่อให้ได้ธีม Premium/Clean
- การจัดการ Event Listener ใน Vue Component (onMounted, onUnmounted) เพื่อป้องกัน Memory Leak
รายละเอียด \*
- พัฒนาระบบ Filter หมวดหมู่คอร์สเรียน เชื่อมโยงหน้าแรก (Home) กับหน้าค้นหา (Browse)
- ปรับแก้โครงสร้าง Code ในหน้า `index.vue` ให้ใช้ Script Setup ทั้งหมดเพื่อลดความซับซ้อนและแก้ปัญหาการเรียกใช้ตัวแปร
- ปรับดีไซน์ส่วน Call to Action (CTA) ในหน้า `browse/index.vue` และ `browse/recommended.vue` ให้ดูสว่างและทันสมัยขึ้นโดยใช้แสงและเงา (Blue Glow)
ปัญหาและอุปสรรค \*
- พบปัญหาการทำงานร่วมกันระหว่าง Options API และ Script Setup ในไฟล์ `index.vue` ทำให้ฟังก์ชันบางตัวเรียกใช้ไม่ได้
- ระบบ Filter เดิมไม่ทำงานเมื่อกดย้อนกลับ (Back Button) เนื่องจากไม่ได้ Watch การเปลี่ยนแปลงของ URL Query
- การดึงข้อมูลคอร์สเริ่มต้นได้ไม่ครบเนื่องจาก API มีการกำหนด Limit ไว้
รายละเอียด \*
- แก้ไขโดยการยุบรวม Code ทั้งหมดให้เป็นรูปแบบ Script Setup มาตรฐานเดียว
- เพิ่ม `watch(() => route.query.category)` ในหน้า Browse เพื่อให้อัปเดตข้อมูลทุกครั้งที่ URL เปลี่ยน
- เพิ่มพารามิเตอร์ `limit: 1000` ในการเรียก API เพื่อดึงข้อมูลคอร์สทั้งหมดมาแสดง
หลักฐานการปฏิบัติงาน (เฉพาะไฟล์ JPG, JPEG, PNG, PDF.)
- แก้ไขไฟล์ `pages/index.vue` (เพิ่ม goBrowse, ย้าย Logic)
- แก้ไขไฟล์ `pages/browse/index.vue` (เพิ่ม Query Watcher, ปรับ UI)
- แก้ไขไฟล์ `pages/browse/recommended.vue` (ปรับ UI ส่วน CTA)
- แก้ไขไฟล์ `components/layout/LandingHeader.vue` (แก้ Memory Leak)
---
วันที่บันทึกปฏิบัติงาน \*
19/02/2026
องค์ความรู้ที่ได้รับ \*
- การออกแบบ Dashboard Layout แบบ Modern Grid (SkillLane Style/Reference Style)
- การใช้ `q-carousel` (Quasar) ทำ Image Slider แบนเนอร์ประชาสัมพันธ์
- การจัดการแสดงผลข้อมูล Course Card แยกตามสถานะ (Enrolled/Recommended/Free)
- การปรับแต่ง Menu Navigation (`useNavItems`) เพื่อลดความซ้ำซ้อนใน Sidebar
รายละเอียด \*
- ออกแบบและพัฒนาหน้า Dashboard ใหม่ (`pages/dashboard/index.vue`)
1. เพิ่ม Banner Slide ขนาดใหญ่ด้านบน
2. แสดงรายการ "คอร์สที่คุณกำลังเรียน" (In Progress Courses) พร้อม Progress Bar
3. แสดงรายการ "คอร์สแนะนำ" (Recommended Courses)
4. เพิ่มหมวด "คอร์สฟรี" (Free Courses)
- ปรับแต่ง Sidebar Navigation (`composables/useNavItems.ts`) ลบเมนูที่ไม่จำเป็นออก (Online Courses, Recommended, Announcements) ให้เหลือเฉพาะเมนูหลัก
ปัญหาและอุปสรรค \*
- การแสดงผล Carousel มีปุ่ม Arrows เกะกะสายตาบนแบนเนอร์
- ต้องการแยกข้อมูลคอร์สฟรีออกจากคอร์สทั่วไป แต่ API ยังไม่มี Filter โดยตรง
รายละเอียด \*
- แก้ไข Property ของ `q-carousel` โดยลบ `arrows` ออก เพื่อให้เหลือเฉพาะ Navigation Dots ด้านล่าง
- เขียน Logic กรองข้อมูลฝั่ง Client (Client-side filtering) สำหรับคอร์สฟรี โดยตรวจสอบจาก `price === 0` หรือ `is_free: true`
หลักฐานการปฏิบัติงาน (เฉพาะไฟล์ JPG, JPEG, PNG, PDF.)
- แก้ไขไฟล์ `pages/dashboard/index.vue` (New Dashboard UI)
- แก้ไขไฟล์ `composables/useNavItems.ts` (Clean Sidebar)

View file

@ -1,102 +0,0 @@
# สรุปโครงสร้างและหน้าที่ของไฟล์ (File Architecture Summary)
เอกสารนี้สรุปภาพรวมของแต่ละโฟลเดอร์และไฟล์หลักในระบบ Frontend-Learner ว่าแต่ละไฟล์ทำหน้าที่อะไร และมีการเชื่อมโยง (Dependencies) กันอย่างไร
---
## 1. 📂 หน้าจอหลัก (Pages Directory: `pages/`)
ควบคุมการทำงานและการนำทาง (Routing) ของผู้ใช้งานแต่ละหน้า โดยจะเรียกใช้ `components/` และ `composables/` มาทำงานร่วมกัน
- **`pages/index.vue` (หน้า Landing Page):**
- **หน้าที่:** หน้าแรกสุดของระบบสำหรับผู้เยี่ยมชม (Guest) นำเสนอคอร์สเด่นและจุดเด่นของระบบ
- **การเชื่อมโยง:** ใช้ Layout `landing`, เชื่อมต่อ `useCourse`, `useCategory` และ components เช่น `CourseCard`, `LandingHeader`, `LandingFooter`
- **`pages/auth/` (กลุ่มหน้าเข้าสู่ระบบและสมัครสมาชิก):**
- **`login.vue`:** หน้าเข้าสู่ระบบ (เชื่อม `useAuth` -> `login()`)
- **`register.vue`:** หน้าลงทะเบียนผู้ใช้ใหม่ (เชื่อม `useAuth` -> `register()`)
- **`forgot-password.vue`:** หน้าลืมรหัสผ่าน
- **การเชื่อมโยง:** กลุ่มนี้ใช้ Layout `auth` และ `useFormValidation` เพื่อตรวจสอบฟอร์ม
- **`pages/dashboard/` (กลุ่มหน้าแผงควบคุมหลักของผู้ใช้):**
- **`index.vue`:** หน้า Dashboard หลัก แสดงคอร์สที่กำลังเรียนและคอร์สแนะนำ
- **`my-courses.vue`:** หน้าแสดงคอร์สทั้งหมดที่ผู้เรียนลงทะเบียนแล้ว
- **`profile.vue`:** หน้าจัดการข้อมูลส่วนตัวของผู้ใช้งาน
- **`announcements.vue`:** หน้าแสดงประกาศระบบและประกาศจากคอร์ส
- **การเชื่อมโยง:** ใช้ Layout `default`, เชื่อม `useAuth`, `useCourse`, และ components โซน `layout/` กับ `course/`
- **`pages/classroom/` (กลุ่มหน้าห้องเรียนเมื่อเข้าสู่คอร์ส):**
- **`learning.vue`:** หน้าจอหลักของการเรียน (มี Video Player เนื้อหาบทเรียน และคอมเมนต์) ควบคุมสถานะความคืบหน้า (Progress)
- **`quiz.vue`:** หน้า Landing ก่อนเริ่มทำแบบทดสอบ แสดงรายละเอียดและสถิติ
- **การเชื่อมโยง:** เชี่อมต่อ `useCourse`, `useQuizRunner` และเรียกใช้คอมโพเนนต์กลุ่ม `classroom/`
- **`pages/quiz/[id].vue` (หน้าจอขณะทำข้อสอบ):**
- **หน้าที่:** นำเสนอตัวคำถามแบบ Interactive และตัวเลือกการตอบ (ข้อความ, ตัวเลือกเดี่ยว, หลายตัวเลือก)
- **การเชื่อมโยง:** เชื่อมกับตัวจัดการสถานะ `useQuizRunner` หนักที่สุด
- **`pages/course/[id].vue` (หน้ารายละเอียดคอร์สก่อนซื้อ/ลงทะเบียน):**
- **การเชื่อมโยง:** เชื่อมต้อ `useCourse` เพื่อดึงรายละเอียดแบบ SSR (Server-Side Rendering)
---
## 2. 🧩 ตัวจัดการตรรกะและข้อมูล (Composables Directory: `composables/`)
เปรียบเสมือน "สมอง" ของระบบ แยกการจัดการข้อมูล (State) และการเรียก API (Fetch) ออกจากหน้าจอ (UI)
- **`useAuth.ts`:**
- **หน้าที่:** จัดการ Login, Register, Logout, ระบบ Token (Access & Refresh), ดึง Data ของผู้ใช้
- **ถูกเรียกใช้โดย:** แทบทุกหน้าจอและ `middleware/auth.ts`
- **`useCourse.ts`:**
- **หน้าที่:** ศูนย์รวม API คอร์สเรียน (ดึงรายการคอร์ส, รายละเอียด, รายชื่อบทเรียน, การลงทะเบียน, ส่งความคืบหน้า, โหลดหน้าวิดีโอ)
- **ถูกเรียกใช้โดย:** `pages/dashboard/`, `pages/classroom/`, `pages/course/` และคอมโพเนนต์ที่แสดงรายการคอร์ส
- **`useQuizRunner.ts`:**
- **หน้าที่:** จัดการสถานะข้อสอบขณะรัน (Current Question), เก็บคำตอบ, ข้ามคำถาม, บันทึกส่งคะแนนเมื่อจบเกม
- **ถูกเรียกใช้โดย:** `pages/quiz/[id].vue` และ `pages/classroom/quiz.vue`
- **`useCategory.ts`:** ดึงหมวดหมู่หลักสูตร นำไปใช้ทำ Filter ใน `index.vue` และ `my-courses.vue`
- **`useThemeMode.ts`:** จัดการโหมดสว่าง-มืด (Dark/Light mode) บันทึกและซิงค์กับ LocalStorage
- **`useFormValidation.ts`:** ดักจับ Error และ Validate กฎ (Rules) ต่างๆ ในฟอร์มรหัสผ่าน, สมัครสมาชิก
- **`useNavItems.ts`:** จัดการรายการเมนูด้านซ้าย (Sidebar Links) เปลี่ยนเมนูตาม Role อัตโนมัติ
---
## 3. 📦 คอมโพเนนต์หน้าจอ (Components Directory: `components/`)
ส่วนประกอบของ UI ที่สามารถนำไปใช้ซ้ำได้ (Reusable) เพื่อลดความซ้ำซ้อนในโค้ดฝั่งหน้าจอ
- **`layout/`:**
- **`AppHeader.vue` & `AppSidebar.vue`:** ประกอบร่างเป็นเมนูและส่วนหัวของ Layout `default`
- **`LandingHeader.vue` & `LandingFooter.vue`:** เมนูส่วนหน้าของเว็บ Guest
- **`classroom/`:**
- **`VideoPlayer.vue`:** ตัวเล่นวิดีโออัจฉริยะ (จัดการ Youtube API และ Native Video) ส่งเวลาดูคืนหน้า `learning.vue` เสมอ
- **`CurriculumSidebar.vue`:** แถบขวาในห้องเรียนเพื่อกดเปลี่ยนบทเรียน แสดงสถานะล็อก/ผ่าน/กำลังเรียน
- **`AnnouncementModal.vue`:** เด้ง Pop-up แจ้งข่าวสารในคอร์สเรียน
- **`course/`:**
- **`CourseCard.vue`:** การ์ดสี่เหลี่ยมแสดงหน้าปกคอร์ส (ถูกใช้ใน `index.vue` และ `my-courses.vue`)
- **`profile/`:**
- แบบฟอร์มแก้ไขแยกย่อยของหน้า `profile.vue` เช่น ฟอร์มเปลี่ยนรหัส (`PasswordChangeForm`)
- **`common/`:**
- คอมโพเนนต์จิปาถะทั่วไปที่ใช้ทุกที่ เช่น สวิตช์เปลี่ยนภาษา (`LanguageSwitcher`) สปินเนอร์โหลดของระบบ (`GlobalLoader.vue`)
---
## 4. 🖼 โครงสร้างเลย์เอาต์ (Layouts Directory: `layouts/`)
เป็นตัวคลุม (Wrapper) พื้นโครงสร้างเว็บของหน้าจอนั้นๆ ไม่ให้กระตุกเมื่อเปลี่ยนเนื้อหา
- **`default.vue`:** เลย์เอาต์หลัก มี Header และ Sidebar ใช้สำหรับ Dashboard
- **`auth.vue`:** เลย์เอาต์เรียบง่ายตรงกลางจอ มีพื้นหลังเบลอๆ สำหรับหน้า Login/Register
- **`landing.vue`:** เลย์เอาต์สำหรับหน้า `index.vue` เต็มจอแบบ Modern นำเสนอผลิตภัณฑ์
- **`dashboard.vue`:** เลย์เอาต์สำหรับมุมมอง Dashboard บางชนิด (ขึ้นอยู่กับโปรเจกต์)
---
## 5. 🛡 ตัวกรองหน้าและยามรักษาความปลอดภัย (Middleware: `middleware/`)
- **`auth.ts`:** ตรวจสอบผู้ใช้ก่อนเข้าหน้าใดๆ เสมอ (Route Guard)
- ถ้ายังไม่ Login จะถูกดีดไปหน้า `/auth/login`
- ถ้าจะเข้าหน้า Login แต่มี Token ล็อกอินแล้ว จะผลักกลับไปหน้า `/dashboard`
- มันจะพึ่งพา Token ที่จัดการผ่าน `useAuth`
---
### ภาพรวมการหมุนเวียนข้อมูล (Data Flow Example)
1. **ล็อกอิน:** หน้า `login.vue` กดยืนยัน -> เรียก `useAuth.login()` -> ได้ Token และ Role ยัดลง Local Cache (Cookie)
2. **เข้าห้องเรียน:** หน้า `index.vue` กดคอร์ส -> ส่งเข้า `middleware/auth.ts` เช็คผ่านเข้าหน้าเรียน -> `pages/course/[id].vue` เรียก `useCourse.fetchCourseById(id)` -> ส่งข้อมูลให้ UI
3. **เปิดคลิปเรียน:** `learning.vue` ส่ง URL ให้ `<VideoPlayer />` เล่น
4. **บันทึกเวลาเรียน:** `VideoPlayer` เฝ้าดูเวลา (TimeUpdate) -> รายงานตัวเลขกลับมายังฟังก์ชัน `handleVideoTimeUpdate()` ที่อยู่ใน `learning.vue` -> เรียก `useCourse.saveVideoProgress()` ส่ง API กลับ Database

View file

@ -1,180 +1,187 @@
# Frontend-Learner (Web) — Technical Documentation # 🛠️ Web Development Documentation: e-Learning Platform (Frontend)
เอกสารฉบับนี้สรุปรายละเอียดทางเทคนิค โครงสร้างโค้ด และกลไกการทำงานของระบบ **Frontend-Learner (ฝั่งผู้เรียน)** เอกสารฉบับนี้สรุปรายละเอียดทางเทคนิค โครงสร้างโค้ด และการทำงานของระบบ **Frontend-Learner** (อัปเดตล่าสุด: กุมภาพันธ์ 2026)
ใช้เป็นคู่มือสำหรับการพัฒนา บำรุงรักษา และขยายระบบต่อไป
> อัปเดตล่าสุด: 27 กุมภาพันธ์ 2026 (อัปเดตคอมเมนต์ภาษาไทย & แก้ไข TypeScript)
--- ---
## Table of Contents ## z 1. Technical Foundation (รากฐานทางเทคนิค)
- [1. Technical Foundation](#1-technical-foundation) รวมข้อมูลเครื่องมือ, ระบบความปลอดภัย และประสิทธิภาพการทำงานไว้ด้วยกัน
- [1.1 Tech Stack](#11-tech-stack)
- [1.2 Security & Authentication](#12-security--authentication)
- [2. Project Architecture](#2-project-architecture)
- [2.1 Directory Structure](#21-directory-structure)
- [2.2 Shared Infrastructure](#22-shared-infrastructure)
- [3. Logic & Data Layer (Composables)](#3-logic--data-layer-composables)
- [4. Branding & UI Policy](#4-branding--ui-policy)
- [4.1 Theme Strategy](#41-theme-strategy)
- [4.2 UI Elements](#42-ui-elements)
- [5. Core Feature Highlights](#5-core-feature-highlights)
- [6. Maintenance & Performance Guidelines](#6-maintenance--performance-guidelines)
---
## 1. Technical Foundation
รากฐานทางเทคนิคที่ขับเคลื่อนระบบ เพื่อให้ได้ประสิทธิภาพและความเสถียรสูงสุด
### 1.1 Tech Stack ### 1.1 Tech Stack
- **Framework:** Nuxt 3 (Vue 3, Vite, SSR/SPA Hybrid) - **Core:** [Nuxt 3](https://nuxt.com) (v`^3.11.2`), TypeScript `^5.4.5`
- **UI System:** Quasar Framework + Tailwind CSS (Utility-first) - **UI Framework:** Quasar Framework `^2.15.2` (via `nuxt-quasar-ui ^3.0.0`)
- **Typography:** Google Fonts (**Prompt** เป็น Font หลักเพื่อความทันสมัยและอ่านง่าย) - **Styling:** Tailwind CSS `^6.12.0` (Utility) + Vanilla CSS Variables (Theming/Dark Mode)
- **Multilingual:** `@nuxtjs/i18n` (รองรับ JSON-based locales ภาษาไทยและอังกฤษ) - **State Management:** `ref`/`reactive` (Local) + `useState` (Global/Shared State)
- **Programming:** TypeScript (Strict Type Checking) - **Localization:** `@nuxtjs/i18n` (Supports JSON locales in `i18n/locales/`)
- **Media Control:** `useMediaPrefs` (Command Pattern for global volume/mute state)
### 1.2 Security & Authentication ### 1.2 Core Systems & Security
- **Token Management:** ใช้ JWT (Access & Refresh Tokens) จัดเก็บผ่าน `useCookie` - **Authentication:**
โดยตั้งค่าความปลอดภัยระดับ **HTTP-only** และ **SameSite** - ใช้ **JWT** (Access Token 1 วัน, Refresh Token 7 วัน)
- **Middleware:** `auth.ts` ตรวจสอบสิทธิ์การเข้าถึงหน้า Dashboard และ Classroom แบบ Real-time - เก็บ Token ใน `useCookie` (Secure, SameSite)
- **Persistence:** ระบบ Remember Me (จดจำอีเมล) ใช้ `localStorage` แยกส่วนจาก Session - Middleware (`middleware/auth.ts`) ป้องกัน Route ตามสถานะ
เพื่อความปลอดภัยและสะดวกสำหรับผู้ใช้ - **Remember Me:** ระบบจดจำอีเมลลงใน `localStorage` (จำแยกจาก session, ไม่ถูกลบเมื่อ Logout)
- **API Handling:**
- ใช้ `runtimeConfig.public.apiBase` เชื่องโยง Backend
- Auto-attach Bearer Token ใน `useAuth` และ `useCourse`
- **Performance:**
- **Hybrid Progress Saving:** บันทึกเวลาเรียนลง LocalStorage (ถี่) และ Server (Throttle 15s) เพื่อความแม่นยำสูงสุด
- **Caching:** ใช้ `useState` จำข้อมูล Profile และ Categories ลด request
- **Code Quality:** ลบ Log, Dead logic และ Redundant comments ทั่วทั้งโปรเจกต์ (Clean Code Phase)
--- ---
## 2. Project Architecture ## 📂 2. Frontend Structure (โครงสร้างหน้าเว็บและ UI)
โครงสร้างโฟลเดอร์ที่จัดระเบียบตามหลัก Clean Architecture เพื่อความคล่องตัวในการขยายระบบ ### 2.1 Application Routes (`pages/`)
### 2.1 Directory Structure | Module | ไฟล์ | Path | หน้าที่ |
| :---------- | :------------------------- | :---------------------- | :-------------------------------------------- |
| **Public** | `index.vue` | `/` | หน้าแรก Landing Page (**Forced Light Mode**) |
| | `browse/discovery.vue` | `/browse/discovery` | **ระบบค้นหาและ Filter คอร์ส** (Catalog) |
| | `course/[id].vue` | `/course/:id` | **หน้ารายละเอียดคอร์ส** (Course Detail) |
| **Auth** | `auth/login.vue` | `/auth/login` | เข้าสู่ระบบ (**Remember Me**, **Light Mode**) |
| | `auth/register.vue` | `/auth/register` | สมัครสมาชิกผู้เรียน (**Light Mode**) |
| | `auth/forgot-password.vue` | `/auth/forgot-password` | กู้คืนรหัสผ่าน (**Light Mode**) |
| **Student** | `dashboard/index.vue` | `/dashboard` | แดชบอร์ดภาพรวมผู้เรียน |
| | `dashboard/my-courses.vue` | `/dashboard/my-courses` | **คอร์สของฉัน** และดาวน์โหลดใบประกาศฯ |
| | `dashboard/profile.vue` | `/dashboard/profile` | จัดการโปรไฟล์, รูปภาพ, เปลี่ยนรหัสผ่าน |
| | `classroom/learning.vue` | `/classroom/learning` | **ห้องเรียน (Video Player)** & Announcements |
| | `classroom/quiz.vue` | `/classroom/quiz` | การสอบวัดผล (**API-Driven Logic**) |
- `pages/` : ระบบ Routing ทั้งหมด (Landing, Auth, Dashboard, Classroom) ### 2.2 Key Components (`components/`)
- `components/` : UI Components แยกตามความรับผิดชอบ (Common, Layout, Course, Classroom, Profile)
- `composables/` : Business Logic ทั้งหมด (Auth, Course, Theme, Quiz, Navigation)
- `types/` : ศูนย์รวม Interface และ Type definitions ของทั้งระบบ
- `constants/` : แหล่งเก็บข้อมูล Static (เช่น Category cards, Why choose us) เพื่อลดความซ้อนในไฟล์ Vue
- `assets/css/` : `main.css` ที่เป็น Single Source of Truth สำหรับสไตล์และ CSS Variables
- `layouts/` : Master templates (Default, Auth, Dashboard)
- `middleware/` : ตัวกรองความปลอดภัยก่อนเข้าถึงแต่ละหน้า
### 2.2 Shared Infrastructure - **Common (`components/common/`):**
- `GlobalLoader.vue`: Loading indicator ทั่วทั้งแอป
- **Types Architecture:** การสกัด Types จาก Composable ออกมาไว้ที่ `@/types` - `LanguageSwitcher.vue`: ปุ่มเปลี่ยนภาษา (TH/EN)
ช่วยลดความซ้ำซ้อนและป้องกัน Error จากการเปลี่ยนโครงสร้างข้อมูล API - `AppHeader.vue`, `MobileNav.vue`: Navigation หลัก (ใช้ร่วมกับ AppSidebar)
- **Constants System:** การใช้ `@/constants` ช่วยให้การแก้ไขคำโฆษณาหรือข้อมูลหน้าแรกทำได้จากจุดเดียว - `FormInput.vue`: Input field มาตรฐาน
โดยไม่ต้องแก้โค้ด HTML - **Layout (`components/layout/`):**
- `AppSidebar.vue`: Sidebar หลักสำหรับ Dashboard (Collapsible)
- `LandingHeader.vue`: Header เฉพาะสำหรับหน้า Landing Page
- **Course (`components/course/`):**
- `CourseCard.vue`: การ์ดแสดงผลคอร์ส รองรับ Progress และ **Glassmorphism** ในโหมดมืด
- **Discovery (`components/discovery/`):**
- `CategorySidebar.vue`: Sidebar ตัวกรองหมวดหมู่แบบย่อ/ขยายได้
- `CourseDetailView.vue`: หน้ารายละเอียดคอร์สขนาดใหญ่ (Video Preview + Syllabus)
- **Classroom (`components/classroom/`):**
- `CurriculumSidebar.vue`: Sidebar บทเรียนและสถานะการเรียน
- `AnnouncementModal.vue`: Modal แสดงประกาศของคอร์ส
- `VideoPlayer.vue`: Video Player พร้อม Custom Controls และ YouTube Support
- **User / Profile (`components/user/`, `components/profile/`):**
- `UserAvatar.vue`: แสดงรูปโปรไฟล์ (รองรับ Fallback)
- `ProfileEditForm.vue`: ฟอร์มแก้ไขข้อมูลส่วนตัว
- `PasswordChangeForm.vue`: ฟอร์มเปลี่ยนรหัสผ่าน
--- ---
## 3. Logic & Data Layer (Composables) ## 🧠 3. Logic & Data Layer (Composables)
การแยก Logic ออกจาก UI เพื่อความสะอาดและ Testable รวบรวม Logic หลักแยกส่วนตามหน้าที่ (Separation of Concerns)
- `useAuth` ### 3.1 `useAuth.ts` (Authentication & User)
จัดการสถานะ Login, การดึงโปรไฟล์ล่วงหน้า (Pre-fetching), และระบบ Token Refresh
- `useCourse` จัดการสถานะผู้ใช้, ล็อกอิน, และความปลอดภัย
หัวใจของระบบ จัดการตั้งแต่ Catalog, การสมัครเรียน (Enroll), ไปจนถึงการส่งผลการเรียน (Progress)
- `useThemeMode` - **Key Functions:** `login`, `register`, `fetchUserProfile`, `uploadAvatar`, `sendVerifyEmail`
ระบบจัดการธีมกลางที่เชื่อมต่อกับ `localStorage` และ CSS Variables อย่างเป็นระบบ - **Features:** Refresh Token อัตโนมัติ, ตรวจสอบ Role, **Logout Logic ที่ไม่ลบข้อมูลจดจำผู้ใช้**
- `useQuizRunner` ### 3.2 `useCourse.ts` (Course & Classroom)
จัดการสถานะการสอบ เปลี่ยนข้อสอบ และส่งคะแนนไปยัง Backend โดยตรง
- `useNavItems` หัวใจหลักของการเรียนการสอน
Single Source of Truth สำหรับเมนูทั้งหมด (Sidebar, Mobile Drawer, User Menu)
- **Catalog:** `fetchCourses`, `fetchCourseById`, `enrollCourse`
- **Classroom:**
- `fetchCourseLearningInfo`: โครงสร้างบทเรียน (Chapters/Lessons)
- `fetchLessonContent`: เนื้อหาวิดีโอ/Quiz/Attachments
- `fetchCourseAnnouncements`: ดึงข้อมูลประกาศของคอร์ส
- `saveVideoProgress`: บันทึกเวลาเรียน (Sync Server)
- **i18n Support:** `getLocalizedText` ตัวช่วยในการเลือกแสดงผลภาษา (TH/EN) ตาม Locale ปัจจุบันที่ผู้ใช้เลือก อัตโนมัติทั่วทั้งแอป
### 3.3 `useQuizRunner.ts` (Quiz System)
จัดการ Logic การทำข้อสอบ (Production-Ready)
- **Logic:** ควบคุมการเปลี่ยนข้อ, การส่งคำตอบ, และการรับผลลัพธ์จาก API
- **Cleanup:** ลบ Mock delays และ Simulation logic ออกทั้งหมดเพื่อให้ทำงานร่วมกับ API จริงได้ทันที
--- ---
## 4. Branding & UI Policy ## 🎨 4. Design System & Theming
มาตรฐานการออกแบบที่เน้นความ Premium และ Consistent
### 4.1 Theme Strategy ### 4.1 Theme Strategy
- **Public Pages (Landing, Auth, Detail):** บังคับ **Forced Light Mode** - **Framework:** Tailwind CSS + Quasar UI
เพื่อภาพลักษณ์แบรนด์ที่สะอาดและน่าเชื่อถือ - **Light/Dark Mode Policy:**
- **Internal Pages (Dashboard, Learning):** รองรับ **Dark Mode (Oceanic Theme)** - **Public Pages:** บังคับ **Light Mode** (Landing, Course Detail, Auth) เพื่อภาพลักษณ์แบรนด์ที่สะอาดตา
ลดการเมื่อยล้าของสายตาขณะเรียนเป็นเวลานาน - **Dashboard/Learning:** รองรับ **Dark Mode** เต็มรูปแบบ (Oceanic Theme)
- **Transitions:** ใช้ GlobalLoader และ Smooth transitions ทั่วทั้งแอปเพื่อประสบการณ์ที่ลื่นไหล - **Aesthetics:** ปรับปรุงความชัดเจนของ Badge, Icon และสถานะต่างๆ ในหน้าสอบ (Quiz) สำหรับโหมดมืดโดยเฉพาะ ให้มี Contrast สูงและดู Premium
- **Visual Fixes:** แก้ไขปัญหา "Dark Frame" ในหน้า Auth โดยการบังคับสไตล์ระดับ HTML/Body
### 4.2 UI Elements
- **Image 2 Style Categories:** การ์ดหมวดหมู่แบบแนวนอนที่เป็นระเบียบ (Minimalist)
- **Glassmorphism:** พื้นผิวโปร่งแสงใน Dashboard และ Classroom ช่วยให้แอปดูมีมิติ
- **Standardized Icons:** ใช้ Material Icons ผ่าน Quasar ระบบเดียวทั้งหมด
--- ---
## 5. Core Feature Highlights ## 📊 5. Dependency Map (ความสัมพันธ์ไฟล์)
ฟีเจอร์เด่นที่ถูกพัฒนาขึ้นเพื่อผู้เรียนโดยเฉพาะ | หน้าเว็บ (Page) | Components หลัก | Composables หลัก |
| :----------------------- | :--------------------------- | :----------------------------------------------------- |
- **SPA Learning Journey:** การสลับบทเรียนในห้องเรียนเป็นแบบ Single Page App (ไม่มีการ Re-load หน้า) | **Login / Register** | `FormInput` | `useAuth` (Remember Me), `useFormValidation` |
ทำให้การเรียนต่อเนื่อง | **Discovery (Browse)** | `CourseCard` | `useCourse` (Search/Filter), `useCategory` |
- **Hybrid Progress Tracking:** บันทึกเวลาเรียนลง `localStorage` แบบ Real-time และ Sync ขึ้น Server เป็นระยะ | **My Courses** | `CourseCard` (with Progress) | `useCourse` (Certificates) |
เพื่อป้องกันข้อมูลหาย | **Classroom (Learning)** | Video Player, Sidebar | `useCourse` (Progress, Announcements), `useMediaPrefs` |
- **Announcement System:** ระบบแจ้งเตือนในคอร์สพร้อมตัวระบุ "ยังไม่ได้อ่าน" (Unread Badge) | **Quiz** | `QuizHeader`, `QuizContent` | `useQuizRunner` (Real API Integration) |
ที่จำสถานะตามผู้ใช้งาน | **Profile** | `UserAvatar`, `FormInput` | `useAuth` (Upload Avatar, Verify Email) |
- **Interactive Quizzes:** ระบบสอบที่สลับคำถามอัตโนมัติ พร้อมโหมดเฉลย (Answer Review) ที่ชัดเจน
- **Certificate Automation:** ระบบตรวจสอบสิทธิ์ความสำเร็จและออกใบประกาศนียบัตรได้ทันที
--- ---
## 6. Maintenance & Performance Guidelines ## ✅ 6. Project Status (สถานะล่าสุด)
แนวทางสำหรับการพัฒนาต่อยอด ### ✨ Recent Updates (กุมภาพันธ์ 2026)
- **Clean Code:** หลีกเลี่ยงการใช้ `console.log` ในโค้ด Final และลบ Dead Logic ทิ้งทันที 1. **System-Wide Code Cleanup (Phase Final):**
- **Standard Fonts:** ใช้ชุด Font Prompt ผ่านตัวแปร `--font-main` เสมอ - **Refactoring:** ปัดกวาดโค้ดในหน้า `learning`, `quiz`, `discovery`, `dashboard` และ `profile`
- **API Integrity:** ตรวจสอบข้อมูลผ่าน Interface ใน `@/types` ก่อนการใช้งานทุกครั้ง - **Logging:** ลบ `console.log` มหาศาล และ logic ซ้ำซ้อนที่ตกค้างจากการพัฒนา
- **Mobile First:** ทุก Component ต้องรองรับระบบ Master Drawer บนมือถืออย่างสมบูรณ์ - **Structure:** จัดกลุ่มสไตล์และฟังก์ชันให้เป็นระเบียบ อ่านง่ายขึ้นตามมาตรฐาน Clean Code
- **Code Comments (Thai Localization):** ระบบหลักทั้งหมด (เช่น Classroom, Quiz, Dashboard, Profile) ได้รับการแปลคอมเมนต์ในโค้ดเป็นภาษาไทยอย่างละเอียด เพื่อให้ทีมนักพัฒนาชาวไทยสามารถทำความเข้าใจ บำรุงรักษา และขยายระบบได้ง่ายและรวดเร็วที่สุด
- **TypeScript Strictness:** ระบบมีการบังคับใช้ Type ใน TypeScript อย่างเข้มงวด และมีการแก้ไข Linting Errors (เช่นในหน้าต่างทำแบบทดสอบ `quiz/[id].vue`) อย่างสม่ำเสมอ เพื่อลดข้อผิดพลาดในขณะรันไทม์ (Runtime)
--- 2. **Authentication & Security Polish:**
- **Remember Me:** พัฒนาระบบจดจำอีเมลในหน้า Login ให้เสถียร (ใช้ `localStorage`)
- **Smart Logout:** ปรับปรุง `useAuth.logout` ให้ลบข้อมูล Session แต่เก็บข้อมูลที่ผู้ใช้สั่งจำไว้ (อีเมล)
## 7. Getting Started & Setup (การติดตั้งและเริ่มต้นใช้งาน) 3. **UI & Aesthetics (Premium Fixes):**
- **Theme Enforcement:** บังคับหน้าสาธารณะ (Landing/Auth) ให้เป็น Light Mode 100% พร้อมแก้ปัญหากรอบมืด (Dark Frame) ตกค้าง
- **Dark Mode Optimization:** ปรับปรุงสีและ Contrast ในหน้า Dashboard และ Profile ให้สวยงามและอ่านง่ายขึ้นในโหมมืด
สำหรับนักพัฒนาที่จะประเมิน เวิร์กโฟลว์ หรือรัน Frontend-Learner ข้อมูลที่จำเป็นมีดังนี้: 4. **Quiz System Productionization:**
- **useQuizRunner:** แปลงร่างจาก Mock system เป็น API-Ready system (ลบ simulation logic ทั้งหมด)
- **Quiz UI:** ปรับปรุงการนำทางและสถานะการทำข้อสอบให้ลื่นไหล
- **Requirements:** ต้องมี Node.js (แนะนำเวอร์ชัน 18 ขึ้นไป) 5. **Smooth Navigation & Quiz Experience:**
- **Install Dependencies:** ติดตั้งแพ็กเกจด้วยคำสั่ง `npm install` - **SPA Navigation:** เปลี่ยนการสไลด์บทเรียนจาก Hard Reload เป็น SPA Navigation (`router.push`) ทำให้เรียนได้ต่อเนื่อง ไม่ต้องรอโหลดหน้าใหม่
- **Run Development Server:** สำหรันการรันในเครื่องตัวเอง ให้ใช้ `npm run dev` ตัวระบบจะคอยรีเฟรชอัติโนมัติเมื่อโค้ดเปลี่ยน - **Smart Lesson Loading:** ปรับปรุง Error ที่หน้าเว็บชอบเด้งกลับไปบทเรียนที่ 1 เสมอ โดยเปลี่ยนให้ความสำคัญกับ `lesson_id` จาก URL ก่อน
- **Build for Production:** ใช้คำสั่ง `npm run build` และเรียกใช้ระบบผ่าน `node .output/server/index.mjs` หรือรันผ่านคำสั่ง `npm run start` (Nuxt 3) - **UI Simplification:** ลบทิ้ง "Legend/คำอธิบายสถานะ" ในหน้าสอบเพื่อความสะอาดตา (Minimal UI)
- **Sidebar visibility:** ช่วยให้ผู้ใช้เปิด-ปิด Sidebar บน Desktop ได้อย่างอิสระผ่านปุ่ม Hamburger
--- 6. **Internationalization (i18n) Improvements:**
- **Localized Text Logic:** แก้ไขฟังก์ชัน `getLocalizedText` ให้แสดงภาษาตามที่ผู้ใช้สลับจริง (แก้ปัญหาหน้าเว็บเป็นอังกฤษแต่ชื่อวิชาเป็นไทย)
- **Hardcoded Removal:** ทยอยลบข้อความภาษาไทยที่พิมพ์ค้างไว้ในโค้ด (เช่น ใน Sidebar หมวดหมู่) และแทนที่ด้วย i18n keys
- **Boot Sequence Fix:** แก้ไขปัญหาเว็บค้าง (Error 500) ที่เกิดจากการเรียกใช้ภาษาเร็วเกินไปก่อนที่ระบบจะพร้อม (`initialization error`)
## 8. Environment Variables (การตั้งค่าตัวแปรสภาพแวดล้อม) 7. **Classroom & UX Optimization (Mid-February 2026):**
- **SPA Navigation for Learning:** เปลี่ยนระบบเลือกบทเรียนจากการ Reload หน้าเป็น SPA Navigation ทำให้เปลี่ยนวิดีโอ/บทเรียนได้ทันทีโดยไม่ต้อง Refresh หน้าจอ
- **Announcement Persistence:** เพิ่มระบบเช็กสถานะการอ่านประกาศ (Unread Badge) โดยบันทึกสถานะล่าสุดลง LocalStorage แยกตามผู้ใช้และคอร์ส
- **YouTube Resume:** รองรับการเรียนต่อจากจุดเดิมสำหรับวิดีโอ YouTube (Time Seeking via URL parameter)
ระบบฝั่ง Frontend จะเชื่อมต่อกับ API Backend ผ่านตัวแปรตั้งค่า ในการพัฒนาในเครื่องของตนเอง จำเป็นต้องสร้างไฟล์ `.env` ที่ root folder ของ `Frontend-Learner` โดยต้องมีข้อมูลดังต่อไปนี้: 8. **Quiz System Enhancements:**
- **Answer Review Mode:** เพิ่มโหมดเฉลยข้อสอบหลังทำเสร็จ พร้อมการไฮไลท์สีที่ชัดเจน (เขียว = ถูก, แดง = ตอบผิด)
- **Shuffle Logic:** เพิ่มการสลับคำถามและตัวเลือก (Shuffle) เพื่อความโปร่งใสในการสอบ
- **Enhanced Feedback:** ปรับปรุง UI ผลลัพธ์การสอบให้มีความ Premium และเข้าใจง่ายขึ้น
```env 9. **Security & Registration Polish:**
NUXT_PUBLIC_API_BASE=http://localhost:4000/api # เปลี่ยนเป็น URL หลังบ้านของ Production หากนำขึ้นโฮสต์ - **Phone Validation:** เพิ่มระบบตรวจสอบเบอร์โทรศัพท์ในหน้าสมัครสมาชิก (ต้องเป็นตัวเลขและยาวไม่เกิน 10 หลัก)
``` - **Enrollment Alert Logic:** ปรับปรุง Logic การสมัครเรียนให้ตรวจสอบสถานะ Enrollment เดิมก่อน เพื่อป้องกัน API Error และการเรียก request ซ้ำซ้อน
_(หมายเหตุ: Nuxt จะอ่านค่า `NUXT_PUBLIC_API_BASE` เข้าไปใน `useRuntimeConfig().public.apiBase` ให้นักพัฒนาเรียกใช้ใน Composables ตลอดทั้งแอปอัตโนมัติ)_ 10. **Profile & Certificates:**
- **Verification Badge:** เพิ่มการแสดงผลสถานะการยืนยันอีเมลในหน้าโปรไฟล์ พร้อมปุ่มส่งอีเมลยืนยันหากยังไม่ได้ทำ
--- - **Certificate Flow:** ปรับปรุงระบบดาวน์โหลดใบประกาศนียบัตรให้รองรับทั้งการดึงไฟล์เดิมและสั่ง Generate ใหม่หากยังไม่มี
## 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 (กรอบเทากะพริบ) คงรูปทรงของวิดีโอ/แบบทดสอบไว้ก่อน ป้องกันไม่ให้หน้าขาวเพื่อลดอคติโหลดช้าของนักเรียน

View file

@ -1,3 +1,5 @@
# API Configuration # API Configuration
NUXT_PUBLIC_API_BASE_URL=http://localhost:3001/api API_BASE_URL=http://localhost:3001/api
# Application
NODE_ENV=development

View file

@ -24,12 +24,3 @@ logs
!.env.example !.env.example
deploy.ps1 deploy.ps1
*.tar *.tar
# Playwright
tests
tests/.auth/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
playwright.config.ts

View file

@ -342,18 +342,11 @@ const save = async () => {
saving.value = true; saving.value = true;
try { try {
// Convert local datetime to ISO string to preserve timezone
const payload = { ...form.value };
if (payload.published_at) {
const localDate = new Date(payload.published_at.replace(' ', 'T'));
payload.published_at = localDate.toISOString();
}
if (editing.value) { if (editing.value) {
await instructorService.updateAnnouncement(props.courseId, editing.value.id, payload); await instructorService.updateAnnouncement(props.courseId, editing.value.id, form.value);
$q.notify({ type: 'positive', message: 'บันทึกประกาศสำเร็จ', position: 'top' }); $q.notify({ type: 'positive', message: 'บันทึกประกาศสำเร็จ', position: 'top' });
} else { } else {
const created = await instructorService.createAnnouncement(props.courseId, payload); const created = await instructorService.createAnnouncement(props.courseId, form.value);
// Upload pending files // Upload pending files
for (const file of pendingFiles.value) { for (const file of pendingFiles.value) {

View file

@ -32,7 +32,7 @@
<q-item-label caption>{{ instructor.user.email }}</q-item-label> <q-item-label caption>{{ instructor.user.email }}</q-item-label>
</q-item-section> </q-item-section>
<q-item-section side v-if="isPrimaryInstructor && instructor.user_id !== currentUserId"> <q-item-section side v-if="isPrimaryInstructor">
<q-btn flat dense round icon="more_vert"> <q-btn flat dense round icon="more_vert">
<q-menu> <q-menu>
<q-item v-if="!instructor.is_primary" clickable v-close-popup @click="setPrimary(instructor.user_id)"> <q-item v-if="!instructor.is_primary" clickable v-close-popup @click="setPrimary(instructor.user_id)">
@ -139,10 +139,6 @@ const isPrimaryInstructor = computed(() => {
return myRecord?.is_primary === true; return myRecord?.is_primary === true;
}); });
const currentUserId = computed(() => {
return authStore.user?.id ? parseInt(authStore.user.id) : null;
});
// Methods // Methods
const fetchInstructors = async () => { const fetchInstructors = async () => {
loading.value = true; loading.value = true;

View file

@ -83,28 +83,6 @@
</div> </div>
</div> </div>
<!-- Quiz Settings -->
<div class="mt-4 p-4 bg-white rounded-lg border border-blue-100">
<div class="font-semibold text-gray-700 mb-3">การตงคาเพมเต</div>
<div class="flex flex-wrap gap-2">
<q-chip :color="lessonDetail.quiz.shuffle_questions ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.shuffle_questions ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.shuffle_questions ? 'check' : 'close'">
มคำถาม
</q-chip>
<q-chip :color="lessonDetail.quiz.shuffle_choices ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.shuffle_choices ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.shuffle_choices ? 'check' : 'close'">
มตวเลอก
</q-chip>
<q-chip :color="lessonDetail.quiz.show_answers_after_completion ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.show_answers_after_completion ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.show_answers_after_completion ? 'check' : 'close'">
เฉลยหลงทำเสร
</q-chip>
<q-chip :color="lessonDetail.quiz.is_skippable ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.is_skippable ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.is_skippable ? 'check' : 'close'">
ามขอได
</q-chip>
<q-chip :color="lessonDetail.quiz.allow_multiple_attempts ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.allow_multiple_attempts ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.allow_multiple_attempts ? 'check' : 'close'">
ทำซำได
</q-chip>
</div>
</div>
<!-- Questions List --> <!-- Questions List -->
<div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6"> <div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6">
<!-- ... (questions rendering code unchanged) ... --> <!-- ... (questions rendering code unchanged) ... -->

View file

@ -63,7 +63,7 @@
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/admin/audit-log" to="/admin/audit-logs"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2" class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600" active-class="bg-primary-500 text-white hover:bg-primary-600"
> >
@ -91,29 +91,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar';
const $q = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
const handleLogout = () => { const handleLogout = () => {
$q.dialog({
title: 'ยืนยันออกจากระบบ',
message: 'คุณต้องการออกจากระบบหรือไม่?',
persistent: true,
ok: {
label: 'ออกจากระบบ',
color: 'negative',
flat: false
},
cancel: {
label: 'ยกเลิก',
flat: true
}
}).onOk(() => {
authStore.logout(); authStore.logout();
router.push('/login'); router.push('/login');
});
}; };
</script> </script>

View file

@ -2,7 +2,7 @@
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<!-- Sidebar --> <!-- Sidebar -->
<aside class="fixed left-0 top-0 h-screen w-64 bg-white shadow-lg"> <aside class="fixed left-0 top-0 h-screen w-64 bg-white shadow-lg">
<div class="py-6 px-8"> <div class="p-6">
<h2 class="text-xl font-bold text-primary-600">E-Learning</h2> <h2 class="text-xl font-bold text-primary-600">E-Learning</h2>
<p class="text-sm text-gray-500">Instructor Panel</p> <p class="text-sm text-gray-500">Instructor Panel</p>
</div> </div>
@ -46,29 +46,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar';
const $q = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
const handleLogout = () => { const handleLogout = () => {
$q.dialog({
title: 'ยืนยันออกจากระบบ',
message: 'คุณต้องการออกจากระบบหรือไม่?',
persistent: true,
ok: {
label: 'ออกจากระบบ',
color: 'negative',
flat: false
},
cancel: {
label: 'ยกเลิก',
flat: true
}
}).onOk(() => {
authStore.logout(); authStore.logout();
router.push('/login'); router.push('/login');
});
}; };
</script> </script>

View file

@ -35,13 +35,10 @@ export default defineNuxtConfig({
devtools: { enabled: true }, devtools: { enabled: true },
app: { app: {
head: { head: {
title: 'E-Learning-Management', title: 'E-Learning System',
meta: [ meta: [
{ charset: 'utf-8' }, { charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' } { name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
link: [
{ rel: 'icon', type: 'image/png', href: '/icon.png' }
] ]
} }
} }

View file

@ -18,7 +18,6 @@
}, },
"devDependencies": { "devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
"@playwright/test": "^1.58.2",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"nuxt-quasar-ui": "^3.0.0" "nuxt-quasar-ui": "^3.0.0"
} }
@ -2774,22 +2773,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -8800,53 +8783,6 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/portfinder": { "node_modules/portfinder": {
"version": "1.0.38", "version": "1.0.38",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",

View file

@ -7,11 +7,7 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare"
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",
"test:report": "playwright show-report"
}, },
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
@ -25,7 +21,6 @@
}, },
"devDependencies": { "devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
"@playwright/test": "^1.58.2",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"nuxt-quasar-ui": "^3.0.0" "nuxt-quasar-ui": "^3.0.0"
} }

View file

@ -218,7 +218,7 @@
<p>เลอกระยะเวลาทองการเกบไว (ลบขอมลทเกากวากำหนด):</p> <p>เลอกระยะเวลาทองการเกบไว (ลบขอมลทเกากวากำหนด):</p>
<q-select <q-select
v-model="cleanupDays" v-model="cleanupDays"
:options="[7, 15, 30, 60, 90, 180, 365]" :options="[30, 60, 90, 180, 365]"
label="จำนวนวัน" label="จำนวนวัน"
suffix="วัน" suffix="วัน"
outlined outlined

View file

@ -75,7 +75,7 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Stats --> <!-- Stats -->
<div class="bg-white rounded-xl shadow-sm p-6"> <div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="font-semibold text-gray-700 mb-4">รายละเอยด</h3> <h3 class="font-semibold text-gray-700 mb-4">สถ</h3>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500">จำนวนบท</span> <span class="text-gray-500">จำนวนบท</span>
@ -93,14 +93,6 @@
<span class="text-gray-500">แบบทดสอบ</span> <span class="text-gray-500">แบบทดสอบ</span>
<span class="font-medium">{{ quizCount }}</span> <span class="font-medium">{{ quizCount }}</span>
</div> </div>
<div class="flex justify-between">
<span class="text-gray-500">สรางเม</span>
<span>{{ formatDate(course.created_at) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">พเดทลาส</span>
<span>{{ formatDate(course.updated_at) }}</span>
</div>
</div> </div>
</div> </div>
@ -124,6 +116,21 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Timeline -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="font-semibold text-gray-700 mb-4">อมลระบบ</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">สรางเม</span>
<span>{{ formatDate(course.created_at) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">พเดทลาส</span>
<span>{{ formatDate(course.updated_at) }}</span>
</div>
</div>
</div>
</div> </div>
</div> </div>

View file

@ -31,10 +31,8 @@
</div> </div>
</div> </div>
<!-- Search & View Toggle --> <!-- Search -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="flex gap-4 items-center">
<div class="flex-1">
<q-input <q-input
v-model="searchQuery" v-model="searchQuery"
placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..." placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..."
@ -51,28 +49,15 @@
</q-input> </q-input>
</div> </div>
<div class="flex justify-end mb-6">
<q-btn-toggle <q-btn-toggle
v-model="viewMode" v-model="viewMode"
toggle-color="primary" toggle-color="primary"
:options="[ :options="[
{ value: 'card', slot: 'card' }, { label: 'การ์ด', value: 'card' },
{ value: 'table', slot: 'table' } { label: 'ตาราง', value: 'table' }
]" ]"
dense />
rounded
unelevated
class="border"
>
<template v-slot:card>
<q-icon name="view_stream" size="20px" />
<q-tooltip>มมองการ</q-tooltip>
</template>
<template v-slot:table>
<q-icon name="view_list" size="20px" />
<q-tooltip>มมองตาราง</q-tooltip>
</template>
</q-btn-toggle>
</div>
</div> </div>
<!-- Pending Courses List --> <!-- Pending Courses List -->

View file

@ -146,7 +146,7 @@
<div class="card bg-white rounded-lg shadow-sm"> <div class="card bg-white rounded-lg shadow-sm">
<div class="p-6 border-b flex justify-between items-center"> <div class="p-6 border-b flex justify-between items-center">
<h2 class="text-lg font-semibold text-gray-900">จกรรมลาส</h2> <h2 class="text-lg font-semibold text-gray-900">จกรรมลาส</h2>
<q-btn flat color="primary" label="ดูทั้งหมด" to="/admin/audit-log" size="sm" /> <q-btn flat color="primary" label="ดูทั้งหมด" to="/admin/audit-logs" size="sm" />
</div> </div>
<div class="divide-y"> <div class="divide-y">
<div v-if="loading" class="p-8 text-center text-gray-500"> <div v-if="loading" class="p-8 text-center text-gray-500">
@ -182,7 +182,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { adminService, type PendingCourse, type AuditLog } from '~/services/admin.service'; import { adminService, type PendingCourse, type AuditLog } from '~/services/admin.service';
import { useQuasar } from 'quasar';
definePageMeta({ definePageMeta({
layout: 'admin', layout: 'admin',
@ -191,7 +190,6 @@ definePageMeta({
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
const $q = useQuasar();
// State // State
const loading = ref(true); const loading = ref(true);
@ -209,23 +207,8 @@ const goToProfile = () => {
}; };
const handleLogout = () => { const handleLogout = () => {
$q.dialog({
title: 'ยืนยันการออกจากระบบ',
message: 'คุณต้องการออกจากระบบใช่หรือไม่?',
cancel: {
label: 'ยกเลิก',
flat: true,
color: 'grey-8'
},
ok: {
label: 'ออกจากระบบ',
color: 'negative'
},
persistent: true
}).onOk(() => {
authStore.logout(); authStore.logout();
router.push('/login'); router.push('/login');
});
}; };
// Data Fetching // Data Fetching

View file

@ -171,7 +171,7 @@
</div> </div>
<!-- Stats --> <!-- Stats -->
<!-- <div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
<div class="bg-blue-50 p-2 rounded-lg text-center"> <div class="bg-blue-50 p-2 rounded-lg text-center">
<div class="text-2xl font-bold text-blue-800">{{ selectedCourse.chapters_count || 0 }}</div> <div class="text-2xl font-bold text-blue-800">{{ selectedCourse.chapters_count || 0 }}</div>
<div class="text-blue-600 text-sm">Chapters</div> <div class="text-blue-600 text-sm">Chapters</div>
@ -180,37 +180,9 @@
<div class="text-2xl font-bold text-purple-800">{{ selectedCourse.lessons_count || 0 }}</div> <div class="text-2xl font-bold text-purple-800">{{ selectedCourse.lessons_count || 0 }}</div>
<div class="text-purple-600 text-sm">Lessons</div> <div class="text-purple-600 text-sm">Lessons</div>
</div> </div>
</div> -->
</div> </div>
</div> </div>
<!-- Course Structure -->
<div v-if="selectedCourse.chapters && selectedCourse.chapters.length > 0" class="mt-6">
<div class="font-bold text-lg mb-3">โครงสรางหลกสตร (Course Structure)</div>
<div class="space-y-3">
<q-expansion-item
v-for="(chapter, index) in selectedCourse.chapters"
:key="chapter.id"
:label="`บทที่ ${index + 1}: ${chapter.title.th}`"
:caption="`${chapter.lessons.length} บทเรียน`"
header-class="bg-gray-50 rounded-lg"
expand-icon-class="text-primary"
>
<div class="pl-4 pt-2">
<div
v-for="(lesson, lessonIndex) in chapter.lessons"
:key="lesson.id"
class="flex items-center gap-3 py-2 border-b last:border-b-0"
>
<q-icon name="article" color="primary" size="20px" />
<span class="text-gray-700">{{ lessonIndex + 1 }}. {{ lesson.title.th }}</span>
<!-- <span v-if="lesson.title.en" class="text-gray-400 text-xs ml-auto">{{ lesson.title.en }}</span> -->
</div> </div>
</div>
</q-expansion-item>
</div>
</div>
</q-card-section> </q-card-section>
<!-- Inner Loading --> <!-- Inner Loading -->

View file

@ -93,7 +93,7 @@
<q-icon v-else name="person" color="primary" /> <q-icon v-else name="person" color="primary" />
</div> </div>
<div> <div>
<div class="font-medium text-primary-600"> <div class="font-medium text-primary-600 hover:underline cursor-pointer">
{{ getFullName(props.row) }} {{ getFullName(props.row) }}
</div> </div>
<div class="text-sm text-gray-500">{{ props.row.email }}</div> <div class="text-sm text-gray-500">{{ props.row.email }}</div>
@ -216,7 +216,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { adminService, type AdminUserResponse, type RoleResponse } from '~/services/admin.service'; import { adminService, type AdminUserResponse } from '~/services/admin.service';
import { useAuthStore } from '~/stores/auth'; import { useAuthStore } from '~/stores/auth';
definePageMeta({ definePageMeta({
@ -228,7 +228,6 @@ const $q = useQuasar();
// Data // Data
const users = ref<AdminUserResponse[]>([]); const users = ref<AdminUserResponse[]>([]);
const roles = ref<RoleResponse[]>([]);
const loading = ref(true); const loading = ref(true);
const searchQuery = ref(''); const searchQuery = ref('');
const filterRole = ref<string | null>(null); const filterRole = ref<string | null>(null);
@ -287,14 +286,6 @@ const filteredUsers = computed(() => {
}); });
// Methods // Methods
const fetchRoles = async () => {
try {
roles.value = await adminService.getRoles();
} catch (error) {
console.error('Failed to fetch roles:', error);
}
};
const fetchUsers = async () => { const fetchUsers = async () => {
loading.value = true; loading.value = true;
try { try {
@ -337,32 +328,24 @@ const viewUser = (user: AdminUserResponse) => {
showViewModal.value = true; showViewModal.value = true;
}; };
const getRoleLabel = (code: string): string => {
const labels: Record<string, string> = {
INSTRUCTOR: 'Instructor',
STUDENT: 'Student',
ADMIN: 'Admin'
};
return labels[code] || code;
};
const changeRole = (user: AdminUserResponse) => { const changeRole = (user: AdminUserResponse) => {
// Find current role ID from fetched roles const roleIds: Record<string, number> = {
const currentRole = roles.value.find(r => r.code === user.role.code); INSTRUCTOR: 1,
STUDENT: 2,
// Build items from API roles ADMIN: 3
const roleItems = roles.value.map(r => ({ };
label: getRoleLabel(r.code),
value: r.id
}));
$q.dialog({ $q.dialog({
title: 'เปลี่ยน Role', title: 'เปลี่ยน Role',
message: `เลือก Role ใหม่สำหรับ ${user.profile.first_name}`, message: `เลือก Role ใหม่สำหรับ ${user.profile.first_name}`,
options: { options: {
type: 'radio', type: 'radio',
model: (currentRole?.id ?? 0) as any, model: roleIds[user.role.code] as any,
items: roleItems items: [
{ label: 'Instructor', value: 1 },
{ label: 'Student', value: 2 },
{ label: 'Admin', value: 3 }
]
}, },
cancel: true, cancel: true,
persistent: true persistent: true
@ -432,7 +415,6 @@ const exportExcel = () => {
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
fetchRoles();
fetchUsers(); fetchUsers();
}); });
</script> </script>

View file

@ -40,37 +40,28 @@
<!-- Title English --> <!-- Title English -->
<q-input <q-input
v-model="form.title.en" v-model="form.title.en"
label="ชื่อแบบทดสอบ (English) *" label="ชื่อแบบทดสอบ (English)"
outlined outlined
:rules="[val => !!val || 'กรุณากรอกชื่อแบบทดสอบ']"
lazy-rules="ondemand"
hide-bottom-space
/> />
<!-- Content Thai --> <!-- Content Thai -->
<q-input <q-input
v-model="form.content.th" v-model="form.content.th"
label="คำอธิบาย (ภาษาไทย) *" label="คำอธิบาย (ภาษาไทย)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
rows="2" rows="2"
:rules="[val => !!val || 'กรุณากรอกคำอธิบาย']"
lazy-rules="ondemand"
hide-bottom-space
/> />
<!-- Content English --> <!-- Content English -->
<q-input <q-input
v-model="form.content.en" v-model="form.content.en"
label="คำอธิบาย (English) *" label="คำอธิบาย (English)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
rows="2" rows="2"
:rules="[val => !!val || 'กรุณากรอกคำอธิบาย']"
lazy-rules="ondemand"
hide-bottom-space
/> />
</div> </div>
</q-card-section> </q-card-section>
@ -616,6 +607,7 @@ const saveQuestion = async () => {
try { try {
const questionData: CreateQuestionRequest = { const questionData: CreateQuestionRequest = {
question: questionForm.value.text, question: questionForm.value.text,
explanation: { th: '', en: '' },
question_type: 'MULTIPLE_CHOICE', question_type: 'MULTIPLE_CHOICE',
sort_order: editingQuestionIndex.value !== null sort_order: editingQuestionIndex.value !== null
? questions.value[editingQuestionIndex.value].id ? editingQuestionIndex.value + 1 : questions.value.length + 1 ? questions.value[editingQuestionIndex.value].id ? editingQuestionIndex.value + 1 : questions.value.length + 1

View file

@ -39,37 +39,28 @@
<!-- Title English --> <!-- Title English -->
<q-input <q-input
v-model="form.title.en" v-model="form.title.en"
label="ชื่อบทเรียน (English) *" label="ชื่อบทเรียน (English)"
outlined outlined
:rules="[val => !!val || 'กรุณากรอกชื่อบทเรียน']"
lazy-rules="ondemand"
hide-bottom-space
/> />
<!-- Content Thai --> <!-- Content Thai -->
<q-input <q-input
v-model="form.content.th" v-model="form.content.th"
label="คำอธิบาย (ภาษาไทย) *" label="คำอธิบาย (ภาษาไทย)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
rows="3" rows="3"
:rules="[val => !!val || 'กรุณากรอกคำอธิบาย']"
lazy-rules="ondemand"
hide-bottom-space
/> />
<!-- Content English --> <!-- Content English -->
<q-input <q-input
v-model="form.content.en" v-model="form.content.en"
label="คำอธิบาย (English) *" label="คำอธิบาย (English)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
rows="3" rows="3"
:rules="[val => !!val || 'กรุณากรอกคำอธิบาย']"
lazy-rules="ondemand"
hide-bottom-space
/> />
<q-card-section class="flex justify-end gap-2"> <q-card-section class="flex justify-end gap-2">
<q-btn <q-btn

View file

@ -68,28 +68,22 @@
<div class="mb-6"> <div class="mb-6">
<q-input <q-input
v-model="form.description.th" v-model="form.description.th"
label="คำอธิบาย (ภาษาไทย) *" label="คำอธิบาย (ภาษาไทย)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
rows="3" rows="3"
:rules="[val => !!val || 'กรุณากรอกคำอธิบาย']"
lazy-rules="ondemand"
hide-bottom-space
/> />
</div> </div>
<div class="mb-6"> <div class="mb-6">
<q-input <q-input
v-model="form.description.en" v-model="form.description.en"
label="คำอธิบาย (English) *" label="คำอธิบาย (English)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
rows="3" rows="3"
:rules="[val => !!val || 'กรุณากรอกคำอธิบาย']"
lazy-rules="ondemand"
hide-bottom-space
/> />
</div> </div>

View file

@ -156,34 +156,25 @@
/> />
<q-input <q-input
v-model="chapterForm.title.en" v-model="chapterForm.title.en"
label="ชื่อบท (English) *" label="ชื่อบท (English)"
outlined outlined
class="mb-4" class="mb-4"
:rules="[val => !!val || 'กรุณากรอกชื่อบท']"
lazy-rules="ondemand"
hide-bottom-space
/> />
<q-input <q-input
v-model="chapterForm.description.th" v-model="chapterForm.description.th"
label="คำอธิบาย (ภาษาไทย) *" label="คำอธิบาย (ภาษาไทย)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
class="mb-4" class="mb-4"
:rules="[val => !!val || 'กรุณากรอกคำอธิบาย']"
lazy-rules="ondemand"
hide-bottom-space
/> />
<q-input <q-input
v-model="chapterForm.description.en" v-model="chapterForm.description.en"
label="คำอธิบาย (English) *" label="คำอธิบาย (English)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
class="mb-4" class="mb-4"
:rules="[val => !!val || 'กรุณากรอกคำอธิบาย']"
lazy-rules="ondemand"
hide-bottom-space
/> />
<div class="flex justify-end gap-2 mt-4"> <div class="flex justify-end gap-2 mt-4">
@ -217,7 +208,7 @@
/> />
<q-input <q-input
v-model="lessonForm.title.en" v-model="lessonForm.title.en"
label="ชื่อบทเรียน (English) *" label="ชื่อบทเรียน (English)"
outlined outlined
class="mb-4" class="mb-4"
/> />
@ -244,29 +235,22 @@
<q-input <q-input
v-if="lessonForm.type" v-if="lessonForm.type"
v-model="lessonForm.content.th" v-model="lessonForm.content.th"
label="เนื้อหา (ภาษาไทย) *" label="เนื้อหา (ภาษาไทย)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
rows="3" rows="3"
class="mb-4" class="mb-4"
:rules="[val => !!val || 'กรุณากรอกเนื้อหา']"
lazy-rules="ondemand"
hide-bottom-space
/> />
<q-input <q-input
v-if="lessonForm.type" v-if="lessonForm.type"
v-model="lessonForm.content.en" v-model="lessonForm.content.en"
label="เนื้อหา (English) *" label="เนื้อหา (English)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
rows="3" rows="3"
class="mb-4"
:rules="[val => !!val || 'กรุณากรอกเนื้อหา']"
lazy-rules="ondemand"
hide-bottom-space
/> />
<div class="flex justify-end gap-2 mt-4"> <div class="flex justify-end gap-2 mt-4">

View file

@ -39,8 +39,8 @@
<!-- Filter Bar --> <!-- Filter Bar -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="flex gap-4 items-center"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex-1"> <div class="md:col-span-2">
<q-input <q-input
v-model="searchQuery" v-model="searchQuery"
placeholder="ค้นหาหลักสูตร..." placeholder="ค้นหาหลักสูตร..."
@ -62,39 +62,15 @@
dense dense
emit-value emit-value
map-options map-options
style="min-width: 160px"
/> />
<q-btn-toggle
v-model="viewMode"
toggle-color="primary"
:options="[
{ value: 'card', slot: 'card' },
{ value: 'table', slot: 'table' }
]"
dense
rounded
unelevated
class="border"
>
<template v-slot:card>
<q-icon name="grid_view" size="20px" />
<q-tooltip>มมองการ</q-tooltip>
</template>
<template v-slot:table>
<q-icon name="view_list" size="20px" />
<q-tooltip>มมองตาราง</q-tooltip>
</template>
</q-btn-toggle>
</div> </div>
</div> </div>
<!-- Loading --> <!-- Courses Grid -->
<div v-if="loading" class="flex justify-center py-10"> <div v-if="loading" class="flex justify-center py-10">
<q-spinner-dots size="50px" color="primary" /> <q-spinner-dots size="50px" color="primary" />
</div> </div>
<!-- Empty State -->
<div v-else-if="filteredCourses.length === 0" class="bg-white rounded-xl shadow-sm p-10 text-center"> <div v-else-if="filteredCourses.length === 0" class="bg-white rounded-xl shadow-sm p-10 text-center">
<q-icon name="school" size="60px" color="grey-5" class="mb-4" /> <q-icon name="school" size="60px" color="grey-5" class="mb-4" />
<p class="text-gray-500 text-lg">งไมหลกสตร</p> <p class="text-gray-500 text-lg">งไมหลกสตร</p>
@ -106,8 +82,7 @@
/> />
</div> </div>
<!-- Card View --> <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-else-if="viewMode === 'card'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div <div
v-for="course in filteredCourses" v-for="course in filteredCourses"
:key="course.id" :key="course.id"
@ -159,17 +134,26 @@
> >
<q-tooltip>รายละเอยด</q-tooltip> <q-tooltip>รายละเอยด</q-tooltip>
</q-btn> </q-btn>
<!-- <q-btn
flat
dense
icon="edit"
color="primary"
@click="navigateTo(`/instructor/courses/${course.id}/edit`)"
>
<q-tooltip>แกไข</q-tooltip>
</q-btn> -->
<q-space /> <q-space />
<q-btn flat round dense icon="more_vert"> <q-btn flat round dense icon="more_vert">
<q-menu> <q-menu>
<q-list style="min-width: 150px"> <q-list style="min-width: 150px">
<q-item v-if="course.status === 'APPROVED'" clickable v-close-popup @click="duplicateCourse(course)"> <q-item clickable v-close-popup @click="duplicateCourse(course)">
<q-item-section avatar> <q-item-section avatar>
<q-icon name="content_copy" /> <q-icon name="content_copy" />
</q-item-section> </q-item-section>
<q-item-section>ทำสำเนา</q-item-section> <q-item-section>ทำสำเนา</q-item-section>
</q-item> </q-item>
<q-separator v-if="course.status === 'APPROVED'" /> <q-separator />
<q-item clickable v-close-popup @click="confirmDelete(course)"> <q-item clickable v-close-popup @click="confirmDelete(course)">
<q-item-section avatar> <q-item-section avatar>
<q-icon name="delete" color="negative" /> <q-icon name="delete" color="negative" />
@ -183,94 +167,6 @@
</div> </div>
</div> </div>
<!-- Table View -->
<div v-else class="bg-white rounded-xl shadow-sm overflow-hidden">
<q-table
:rows="filteredCourses"
:columns="tableColumns"
row-key="id"
flat
:pagination="tablePagination"
:rows-per-page-options="[10, 20, 50, 0]"
@update:pagination="tablePagination = $event"
>
<!-- Thumbnail + Title -->
<template v-slot:body-cell-title="props">
<q-td :props="props">
<div class="flex items-center gap-3">
<div class="w-16 h-10 rounded overflow-hidden flex-shrink-0 bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center">
<img
v-if="props.row.thumbnail_url"
:src="props.row.thumbnail_url"
:alt="props.row.title.th"
class="w-full h-full object-cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<q-icon v-else name="school" size="20px" color="white" />
</div>
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">{{ props.row.title.th }}</div>
<div class="text-xs text-gray-400 truncate">{{ props.row.title.en }}</div>
</div>
</div>
</q-td>
</template>
<!-- Status Badge -->
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge :color="getStatusColor(props.row.status)">
{{ getStatusLabel(props.row.status) }}
</q-badge>
</q-td>
</template>
<!-- Price -->
<template v-slot:body-cell-price="props">
<q-td :props="props">
<span class="font-medium" :class="props.row.is_free ? 'text-green-600' : 'text-primary-600'">
{{ props.row.is_free ? 'ฟรี' : `฿${parseFloat(props.row.price).toLocaleString()}` }}
</span>
</q-td>
</template>
<!-- Date -->
<template v-slot:body-cell-created_at="props">
<q-td :props="props">
{{ formatDate(props.row.created_at) }}
</q-td>
</template>
<!-- Actions -->
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn flat round dense icon="visibility" color="grey" size="sm" @click="handleViewDetails(props.row)">
<q-tooltip>รายละเอยด</q-tooltip>
</q-btn>
<q-btn flat round dense icon="more_vert" size="sm">
<q-menu>
<q-list style="min-width: 150px">
<q-item v-if="props.row.status === 'APPROVED'" clickable v-close-popup @click="duplicateCourse(props.row)">
<q-item-section avatar>
<q-icon name="content_copy" />
</q-item-section>
<q-item-section>ทำสำเนา</q-item-section>
</q-item>
<q-separator v-if="props.row.status === 'APPROVED'" />
<q-item clickable v-close-popup @click="confirmDelete(props.row)">
<q-item-section avatar>
<q-icon name="delete" color="negative" />
</q-item-section>
<q-item-section class="text-negative">ลบ</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-td>
</template>
</q-table>
</div>
<!-- Rejection Details Dialog --> <!-- Rejection Details Dialog -->
<q-dialog v-model="rejectionDialog"> <q-dialog v-model="rejectionDialog">
<q-card style="min-width: 400px"> <q-card style="min-width: 400px">
@ -360,17 +256,6 @@ const courses = ref<CourseResponse[]>([]);
const loading = ref(true); const loading = ref(true);
const searchQuery = ref(''); const searchQuery = ref('');
const filterStatus = ref<string | null>(null); const filterStatus = ref<string | null>(null);
const viewMode = ref<'card' | 'table'>('card');
// Table config
const tablePagination = ref({ page: 1, rowsPerPage: 10 });
const tableColumns = [
{ name: 'title', label: 'หลักสูตร', field: 'title', align: 'left' as const, sortable: true },
{ name: 'status', label: 'สถานะ', field: 'status', align: 'center' as const, sortable: true },
{ name: 'price', label: 'ราคา', field: 'price', align: 'center' as const, sortable: true },
{ name: 'created_at', label: 'วันที่สร้าง', field: 'created_at', align: 'center' as const, sortable: true },
{ name: 'actions', label: 'จัดการ', field: 'actions', align: 'center' as const }
];
// Status options // Status options
const statusOptions = [ const statusOptions = [
@ -378,8 +263,7 @@ const statusOptions = [
{ label: 'เผยแพร่แล้ว', value: 'APPROVED' }, { label: 'เผยแพร่แล้ว', value: 'APPROVED' },
{ label: 'รอตรวจสอบ', value: 'PENDING' }, { label: 'รอตรวจสอบ', value: 'PENDING' },
{ label: 'แบบร่าง', value: 'DRAFT' }, { label: 'แบบร่าง', value: 'DRAFT' },
{ label: 'ถูกปฏิเสธ', value: 'REJECTED' }, { label: 'ถูกปฏิเสธ', value: 'REJECTED' }
//{ label: '', value: 'ARCHIVED' }
]; ];
// Stats // Stats
@ -391,7 +275,7 @@ const stats = computed(() => ({
rejected: courses.value.filter(c => c.status === 'REJECTED').length rejected: courses.value.filter(c => c.status === 'REJECTED').length
})); }));
// Filtered courses (search only, status is handled server-side) // Filtered courses
const filteredCourses = computed(() => { const filteredCourses = computed(() => {
let result = courses.value; let result = courses.value;
@ -403,6 +287,10 @@ const filteredCourses = computed(() => {
); );
} }
if (filterStatus.value) {
result = result.filter(course => course.status === filterStatus.value);
}
return result; return result;
}); });
@ -410,7 +298,7 @@ const filteredCourses = computed(() => {
const fetchCourses = async () => { const fetchCourses = async () => {
loading.value = true; loading.value = true;
try { try {
courses.value = await instructorService.getCourses(filterStatus.value || undefined); courses.value = await instructorService.getCourses();
} catch (error) { } catch (error) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
@ -422,18 +310,12 @@ const fetchCourses = async () => {
} }
}; };
// Re-fetch when status filter changes
watch(filterStatus, () => {
fetchCourses();
});
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
APPROVED: 'green', APPROVED: 'green',
PENDING: 'orange', PENDING: 'orange',
DRAFT: 'grey', DRAFT: 'grey',
REJECTED: 'red', REJECTED: 'red'
ARCHIVED: 'blue-grey'
}; };
return colors[status] || 'grey'; return colors[status] || 'grey';
}; };
@ -443,8 +325,7 @@ const getStatusLabel = (status: string) => {
APPROVED: 'เผยแพร่แล้ว', APPROVED: 'เผยแพร่แล้ว',
PENDING: 'รอตรวจสอบ', PENDING: 'รอตรวจสอบ',
DRAFT: 'แบบร่าง', DRAFT: 'แบบร่าง',
REJECTED: 'ถูกปฏิเสธ', REJECTED: 'ถูกปฏิเสธ'
ARCHIVED: 'เก็บถาวร'
}; };
return labels[status] || status; return labels[status] || status;
}; };

View file

@ -171,8 +171,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar';
definePageMeta({ definePageMeta({
layout: 'instructor', layout: 'instructor',
middleware: 'auth' middleware: 'auth'
@ -181,7 +179,6 @@ definePageMeta({
const authStore = useAuthStore(); const authStore = useAuthStore();
const instructorStore = useInstructorStore(); const instructorStore = useInstructorStore();
const router = useRouter(); const router = useRouter();
const $q = useQuasar();
// Navigation functions // Navigation functions
const goToProfile = () => { const goToProfile = () => {
@ -193,23 +190,8 @@ const goToSettings = () => {
}; };
const handleLogout = () => { const handleLogout = () => {
$q.dialog({
title: 'ยืนยันการออกจากระบบ',
message: 'คุณต้องการออกจากระบบใช่หรือไม่?',
cancel: {
label: 'ยกเลิก',
flat: true,
color: 'grey-8'
},
ok: {
label: 'ออกจากระบบ',
color: 'negative'
},
persistent: true
}).onOk(() => {
authStore.logout(); authStore.logout();
router.push('/login'); router.push('/login');
});
}; };
// Fetch dashboard data on mount // Fetch dashboard data on mount

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

View file

@ -296,15 +296,6 @@ export interface RecommendedCourse {
}; };
chapters_count: number; chapters_count: number;
lessons_count: number; lessons_count: number;
chapters?: {
id: number;
title: { th: string; en: string };
sort_order: number;
lessons: {
id: number;
title: { th: string; en: string };
}[];
}[];
} }
export interface RecommendedCoursesListResponse { export interface RecommendedCoursesListResponse {
@ -320,25 +311,7 @@ const getAuthToken = (): string => {
return tokenCookie.value || ''; return tokenCookie.value || '';
}; };
// Role interface
export interface RoleResponse {
id: number;
code: string;
}
export const adminService = { export const adminService = {
async getRoles(): Promise<RoleResponse[]> {
const config = useRuntimeConfig();
const token = getAuthToken();
const response = await $fetch<{ roles: RoleResponse[] }>('/api/user/roles', {
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
return response.roles;
},
async getUsers(): Promise<AdminUserResponse[]> { async getUsers(): Promise<AdminUserResponse[]> {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const token = getAuthToken(); const token = getAuthToken();

View file

@ -208,12 +208,8 @@ const authRequest = async <T>(
}; };
export const instructorService = { export const instructorService = {
async getCourses(status?: string): Promise<CourseResponse[]> { async getCourses(): Promise<CourseResponse[]> {
let url = '/api/instructors/courses'; const response = await authRequest<CoursesListResponse>('/api/instructors/courses');
if (status) {
url += `?status=${status}`;
}
const response = await authRequest<CoursesListResponse>(url);
return response.data; return response.data;
}, },