feat: Add initial pages and components for user dashboard, profile, course discovery, and classroom learning with i18n support.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 47s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s

This commit is contained in:
supalerk-ar66 2026-02-20 14:58:18 +07:00
parent f26a94076c
commit e3873f616e
11 changed files with 1046 additions and 685 deletions

View file

@ -21,15 +21,40 @@ const emit = defineEmits<{
const { locale } = useI18n()
// State for expansion items
const chapterOpenState = ref<Record<string, boolean>>({})
// Helper for localization
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
const currentLocale = locale.value as 'th' | 'en'
// Safe locale access
const currentLocale = (locale?.value || 'th') as 'th' | 'en'
return text[currentLocale] || text.th || text.en || ''
}
// Helper: Check if lesson is completed
const isLessonCompleted = (lesson: any) => {
return lesson.is_completed === true || lesson.progress?.is_completed === true
}
// Reactive Chapter Completion Status
// Computes a map of chapterId -> boolean (true if all lessons are completed)
const chapterCompletionStatus = computed(() => {
const status: Record<string, boolean> = {}
if (!props.courseData || !props.courseData.chapters) return status
props.courseData.chapters.forEach((chapter: any) => {
if (chapter.lessons && chapter.lessons.length > 0) {
status[chapter.id] = chapter.lessons.every((l: any) => isLessonCompleted(l))
} else {
status[chapter.id] = false
}
})
return status
})
// Local Progress Calculation
const progressPercentage = computed(() => {
if (!props.courseData || !props.courseData.chapters) return 0
@ -38,11 +63,34 @@ const progressPercentage = computed(() => {
props.courseData.chapters.forEach((c: any) => {
c.lessons.forEach((l: any) => {
total++
if (l.is_completed || l.progress?.is_completed) completed++
if (isLessonCompleted(l)) completed++
})
})
return total > 0 ? Math.round((completed / total) * 100) : 0
})
// Auto-expand chapter containing current lesson
watch(() => props.currentLessonId, (newId) => {
if (newId && props.courseData?.chapters) {
props.courseData.chapters.forEach((chapter: any) => {
const hasLesson = chapter.lessons.some((l: any) => l.id === newId)
if (hasLesson) {
chapterOpenState.value[chapter.id] = true
}
})
}
}, { immediate: true })
// Initialize all chapters as open by default on load
watch(() => props.courseData, (newData) => {
if (newData?.chapters) {
newData.chapters.forEach((chapter: any) => {
if (chapterOpenState.value[chapter.id] === undefined) {
chapterOpenState.value[chapter.id] = true
}
})
}
}, { immediate: true })
</script>
<template>
@ -51,70 +99,110 @@ const progressPercentage = computed(() => {
@update:model-value="(val) => emit('update:modelValue', val)"
show-if-above
bordered
side="left"
:width="280"
side="right"
:width="300"
:breakpoint="1024"
class="bg-slate-50 dark:bg-slate-900 shadow-xl"
content-class="flex flex-col h-full"
>
<div v-if="courseData" class="flex flex-col h-full overflow-hidden">
<!-- Course Progress Header -->
<div class="p-5 border-b border-gray-200 dark:border-white/10 bg-slate-50/50 dark:bg-slate-900/50">
<div class="flex justify-between items-center mb-2">
<!-- 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">
<!-- 1. Header Section (Fixed at Top) -->
<div class="flex-none p-5 border-b border-slate-200 dark:border-white/10 bg-white dark:bg-slate-900 z-10 w-full">
<h2 class="text-sm font-bold mb-4 line-clamp-2 leading-snug block w-full">{{ getLocalizedText(courseData.course.title) }}</h2>
<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-sm font-black text-blue-600 dark:text-blue-400">{{ progressPercentage }}%</span>
</div>
<div class="h-2 w-full bg-slate-200 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
<div class="h-2 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
<div
class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out shadow-[0_0_12px_rgba(37,99,235,0.3)]"
class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out"
:style="{ width: `${progressPercentage}%` }"
></div>
</div>
</div>
<div class="flex-grow scroll">
<q-list padding class="py-2">
<template v-for="chapter in courseData.chapters" :key="chapter.id">
<q-item-label header class="bg-slate-100 dark:bg-slate-800 text-[var(--text-main)] font-bold sticky top-0 z-10 border-b dark:border-white/5 text-sm py-4">
{{ getLocalizedText(chapter.title) }}
</q-item-label>
<q-item
v-for="lesson in chapter.lessons"
:key="lesson.id"
clickable
v-ripple
:active="currentLessonId === lesson.id"
active-class="bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 active-lesson-indicator"
class="px-5 py-3 transition-all duration-200 group relative border-b border-gray-100/50 dark:border-white/5"
@click="!lesson.is_locked && emit('select-lesson', lesson.id)"
:disable="lesson.is_locked"
<!-- 2. Curriculum List (Scrollable Area) -->
<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">
<div v-for="(chapter, idx) in courseData.chapters" :key="chapter.id" class="block w-full mb-3">
<!-- Chapter Accordion -->
<q-expansion-item
v-model="chapterOpenState[chapter.id]"
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"
expand-icon-class="text-slate-400"
>
<q-item-section avatar v-if="lesson.is_locked">
<q-icon name="lock" size="xs" color="grey" />
</q-item-section>
<template v-slot:header>
<div class="flex items-center w-full py-3 text-slate-900 dark:text-white">
<div class="mr-3 flex-shrink-0">
<!-- Chapter Indicator (Check or Number) -->
<div class="w-7 h-7 rounded-full border-2 flex items-center justify-center transition-colors font-bold"
:class="chapterCompletionStatus[chapter.id]
? 'border-green-500 text-green-500 bg-green-50 dark:bg-green-500/10'
: '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" />
<span v-else class="text-[10px]">{{ Number(idx) + 1 }}</span>
</div>
</div>
<!-- Explicitly handle text overflow -->
<div class="flex-1 min-w-0 pr-2 overflow-hidden">
<div class="font-bold text-sm leading-tight mb-0.5 truncate block w-full">{{ getLocalizedText(chapter.title) }}</div>
<div class="text-[10px] text-slate-500 dark:text-slate-400 font-normal truncate block w-full">
{{ chapter.lessons.length }} {{ $t('course.lessonsUnit') }}
</div>
</div>
</div>
</template>
<q-item-section>
<q-item-label
class="text-sm font-bold line-clamp-2 transition-colors"
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-700 dark:text-slate-300'"
<!-- Lessons List -->
<div class="bg-slate-50 dark:bg-[#0f1219]/50 border-t border-slate-100 dark:border-slate-800 w-full">
<div
v-for="(lesson, lIdx) in chapter.lessons"
: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-800/50 w-full"
:class="currentLessonId === lesson.id
? 'border-blue-600 bg-blue-50 dark:bg-blue-900/10'
: 'border-transparent'"
@click="!lesson.is_locked && emit('select-lesson', lesson.id)"
>
<!-- Lesson Status Icon -->
<div class="mr-3 flex-shrink-0">
<!-- Completed (Takes Precedence) -->
<q-icon v-if="isLessonCompleted(lesson)"
name="check_circle"
class="text-green-500"
size="20px"
/>
<!-- Active/Playing (If not completed) -->
<q-icon v-else-if="currentLessonId === lesson.id"
name="play_circle_filled"
class="text-blue-600 dark:text-blue-400 animate-pulse"
size="20px"
/>
<!-- Locked -->
<q-icon v-else-if="lesson.is_locked"
name="lock"
class="text-slate-400 opacity-70"
size="18px"
/>
<!-- Not Started -->
<div v-else class="w-[18px] h-[18px] rounded-full border-2 border-slate-300 dark:border-slate-600"></div>
</div>
<div class="flex-1 min-w-0 overflow-hidden">
<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'"
>
{{ getLocalizedText(lesson.title) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="flex items-center">
<q-icon v-if="lesson.is_completed || lesson.progress?.is_completed" name="check_circle" color="positive" size="18px" />
<q-icon v-else-if="currentLessonId === lesson.id" name="play_arrow" color="primary" size="18px" class="animate-pulse" />
<q-icon v-else-if="lesson.is_locked" name="lock" color="grey-4" size="18px" />
<q-icon v-else name="radio_button_unchecked" color="grey-3" size="18px" />
</div>
</q-item-section>
</q-item>
</template>
</div>
</div>
</div>
</q-expansion-item>
</div>
</q-list>
</div>
</div>
@ -126,31 +214,18 @@ const progressPercentage = computed(() => {
</template>
<style scoped>
.active-lesson-indicator {
position: relative;
}
.active-lesson-indicator::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #2563eb; /* blue-600 */
border-radius: 0 4px 4px 0;
}
.scroll::-webkit-scrollbar {
/* Custom scrollbar for better aesthetics */
::-webkit-scrollbar {
width: 4px;
}
.scroll::-webkit-scrollbar-track {
::-webkit-scrollbar-track {
background: transparent;
}
.scroll::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.05);
border-radius: 10px;
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.dark .scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.05);
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
</style>

View file

@ -33,6 +33,19 @@ const formatPrice = (price: number) => {
}
const enrollmentLoading = ref(false);
const activeTab = ref('curriculum');
const totalLessons = computed(() => {
if (!props.course?.chapters) return 0;
return props.course.chapters.reduce((acc: number, chapter: any) => acc + (chapter.lessons?.length || 0), 0);
});
const totalDuration = computed(() => {
if (!props.course?.chapters) return 0;
return props.course.chapters.reduce((acc: number, chapter: any) => {
return acc + (chapter.lessons?.reduce((lAcc: number, lesson: any) => lAcc + (lesson.duration_minutes || 0), 0) || 0);
}, 0);
});
const handleEnroll = () => {
if(!props.course) return;
@ -42,7 +55,6 @@ const handleEnroll = () => {
// In this pattern, we just emit.
setTimeout(() => enrollmentLoading.value = false, 2000); // Safety timeout
};
</script>
<template>
@ -104,19 +116,37 @@ const handleEnroll = () => {
</div>
</div>
<!-- Curriculum Preview -->
<div class="bg-slate-50 dark:bg-slate-900 rounded-3xl p-6 md:p-8 border border-slate-200 dark:border-white/5">
<div class="flex items-center justify-between mb-8">
<!-- Course Detail - Single Page Layout -->
<div class="space-y-10">
<!-- Instructor Info -->
<div class="flex flex-col sm:flex-row gap-6 items-start sm:items-center pb-8 border-b border-slate-200 dark:border-slate-800">
<q-avatar size="64px">
<img :src="course.instructor?.profile?.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" />
</q-avatar>
<div>
<h3 class="text-xl font-black text-slate-900 dark:text-white mb-1 flex items-center gap-2">
<div class="text-sm text-slate-500 mb-1 font-bold uppercase tracking-wider">{{ $t('course.instructor') }}</div>
<div class="font-bold text-xl text-slate-800 dark:text-white">
{{ course.instructor?.profile?.first_name || 'Unknown' }} {{ course.instructor?.profile?.last_name || 'Instructor' }}
</div>
<div class="text-slate-500 text-sm mt-1">{{ course.instructor?.email || 'No contact info' }}</div>
</div>
</div>
<!-- Curriculum / Lesson Details -->
<div>
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-slate-900 dark:text-white">
{{ $t('course.courseContent') }}
</h3>
<div class="text-sm font-bold text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-white/5 px-4 py-2 rounded-full">
{{ totalLessons }} {{ $t('course.lessons') }} {{ totalDuration }} {{ $t('quiz.minutes') }}
</div>
<q-icon name="keyboard_command_key" class="text-slate-200 dark:text-slate-800" size="32px" />
</div>
<div class="space-y-4">
<div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="group">
<!-- Chapter Header -->
<div class="px-6 py-4 bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-white/5 font-black text-slate-800 dark:text-white flex justify-between items-center mb-2 shadow-sm">
<span class="flex items-center gap-3">
<span class="w-7 h-7 flex items-center justify-center bg-slate-100 dark:bg-white/10 rounded-lg text-xs font-bold font-mono">{{ Number(idx) + 1 }}</span>
@ -124,20 +154,24 @@ const handleEnroll = () => {
</span>
<span class="text-[10px] uppercase font-black tracking-widest text-slate-400 opacity-60">{{ chapter.lessons?.length || 0 }} {{ $t('course.lessonsUnit') }}</span>
</div>
<!-- Lessons List -->
<div class="ml-4 pl-4 border-l-2 border-slate-100 dark:border-slate-800 space-y-1 mt-3">
<div v-for="lesson in chapter.lessons" :key="lesson.id" class="px-5 py-3 flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-white/5 rounded-xl transition-all hover:translate-x-1">
<div class="w-8 h-8 rounded-full flex items-center justify-center" :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'">
<q-icon
:name="lesson.type === 'VIDEO' ? 'play_arrow' : 'article'"
size="16px"
/>
</div>
<span class="flex-1 font-bold">{{ getLocalizedText(lesson.title) }}</span>
<span v-if="lesson.duration_minutes" class="text-slate-400 dark:text-slate-500 text-[10px] font-bold">{{ lesson.duration_minutes }} {{ $t('quiz.minutes') }}</span>
<q-icon v-if="lesson.is_locked !== false" name="lock" size="14px" class="text-slate-300 dark:text-slate-600" />
<span class="flex-1 font-bold truncate">{{ getLocalizedText(lesson.title) }}</span>
<span v-if="lesson.duration_minutes" class="text-slate-400 dark:text-slate-500 text-[10px] font-bold shrink-0">{{ lesson.duration_minutes }} {{ $t('quiz.minutes') }}</span>
<q-icon v-if="lesson.is_locked !== false" name="lock" size="14px" class="text-slate-300 dark:text-slate-600 shrink-0" />
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!course.chapters || course.chapters.length === 0" class="flex flex-col items-center justify-center py-12 text-slate-400 dark:text-slate-500 bg-white/50 dark:bg-slate-900/50 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="menu_book" size="40px" class="mb-2 opacity-50" />
<p class="text-sm font-medium">{{ $t('course.noContent') }}</p>
@ -147,6 +181,8 @@ const handleEnroll = () => {
</div>
</div>
<!-- Right: Enrollment Card -->
<div class="lg:col-span-1">
<div class="sticky top-24">

View file

@ -5,39 +5,43 @@
* Uses Quasar QToolbar.
*/
import { ref, computed } from 'vue'
import { ref, computed } from "vue";
const props = defineProps<{
/** Controls visibility of the search bar */
showSearch?: boolean
showSearch?: boolean;
/** Controls visibility of the sidebar toggle button */
showSidebarToggle?: boolean
showSidebarToggle?: boolean;
/** Type of navigation links to display */
navType?: 'public' | 'learner'
}>()
navType?: "public" | "learner";
}>();
const emit = defineEmits<{
/** Emitted when the hamburger menu is clicked */
toggleSidebar: []
}>()
toggleSidebar: [];
}>();
const { t } = useI18n()
const route = useRoute()
const { t } = useI18n();
const route = useRoute();
// Automatically determine navType based on route if not explicitly passed
const navTypeComputed = computed(() => {
if (props.navType) return props.navType
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 learnerRoutes = ["/dashboard", "/browse", "/classroom", "/course"];
return learnerRoutes.some((r) => route.path.startsWith(r))
? "learner"
: "public";
});
const searchText = ref('')
const searchText = ref("");
</script>
<template>
<q-toolbar class="bg-white text-slate-900 h-16 shadow-sm border-none p-0">
<div class="container mx-auto w-full px-6 md:px-12 flex items-center h-full">
<div
class="container mx-auto w-full px-6 md:px-12 flex items-center h-full"
>
<!-- Mobile Menu Toggle -->
<q-btn
v-if="showSidebarToggle !== false && navTypeComputed !== 'learner'"
@ -50,22 +54,35 @@ const searchText = ref('')
/>
<!-- 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">
<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>
<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">
<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="ค้นหาคอร์สเรียน..."
: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}`)"
>
@ -77,24 +94,37 @@ const searchText = ref('')
<!-- 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">
หนาหล
<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">
คอรสเรยนทงหมด
<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
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">
<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">
@ -104,15 +134,26 @@ const searchText = ref('')
</q-list>
</q-menu>
</div>
<div class="cursor-pointer hover:text-purple-600 flex items-center gap-1 transition-colors">
<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
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>
<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>
@ -122,7 +163,6 @@ const searchText = ref('')
<div class="flex items-center gap-2 sm:gap-4 text-gray-500">
<!-- Search Icon -->
<!-- Language -->
<LanguageSwitcher />

View file

@ -5,7 +5,26 @@
},
"dashboard": {
"welcomeTitle": "Welcome back",
"welcomeSubtitle": "Today is a great day to learn something new. Let's gain more knowledge!"
"welcomeSubtitle": "Today is a great day to learn something new. Let's gain more knowledge!",
"heroTitle": "Continually upskill yourself",
"heroSubtitle": "to achieve your goals",
"heroDesc": "How many minutes have you learned today? Let's build a great learning habit. We have many new recommended courses waiting for you.",
"goToMyCourses": "Go to My Courses",
"searchNewCourses": "Find New Courses",
"continueLearningTitle": "Continue learning with your courses",
"myCourses": "My Courses",
"studyAgain": "Study Again",
"continue": "Continue",
"startNewCourse": "Start new courses to fill this section",
"knowledgeLibrary": "Knowledge Library",
"libraryDesc": "You can choose to learn from courses you own",
"chooseLibrary": "Choose to learn from your knowledge library",
"viewAll": "View All",
"emptyLibraryTitle": "No courses in library yet",
"emptyLibraryDesc": "Start learning new things today. Browse interesting courses to develop your skills.",
"viewAllCourses": "View All Courses",
"recommendedCourses": "Recommended Courses",
"noRecommended": "No recommended courses found"
},
"menu": {
"continueLearning": "Continue Learning",
@ -40,19 +59,29 @@
"studyAgain": "Study Again",
"downloadCertificate": "Download Certificate",
"completed": "Completed",
"includes": "Course includes",
"fullLifetimeAccess": "Full lifetime access",
"accessOnMobile": "Access on mobile and TV",
"lifetimeAccess": "Lifetime access",
"unlimitedQuizzes": "Unlimited quizzes",
"satisfactionGuarantee": "Satisfaction guarantee, 7-day refund",
"noContent": "No content available yet",
"buyNow": "Buy this course",
"enrollFree": "Enroll for free",
"loginToEnroll": "Log in to enroll",
"minutes": "Minutes",
"noVideoPreview": "Video preview not available",
"videoNotSupported": "Your browser does not support the video tag"
"videoNotSupported": "Your browser does not support the video tag",
"aboutCourse": "About Course",
"lessonDetails": "Lesson Details",
"courseStats": {
"level": "Level",
"duration": "Duration",
"lessons": "Lessons",
"students": "Students"
},
"certificatePreview": "Certificate Preview",
"certificateDesc": "Upon completion and passing criteria",
"includes": "This course includes",
"fullLifetimeAccess": "Full lifetime access",
"accessOnMobile": "Access on mobile and tablet",
"buyNow": "Buy Now"
},
"sidebar": {
"overview": "Home",
@ -76,7 +105,9 @@
"showAll": "Show All",
"loadMore": "Load More",
"backToCatalog": "Back to Catalog",
"selectable": "Selected"
"selectable": "Selected",
"foundTotal": "Found Total",
"items": "items"
},
"myCourses": {
"filterAll": "All",
@ -109,6 +140,8 @@
"email": "Email",
"phone": "Phone",
"joinedAt": "Joined",
"generalInfo": "General Information",
"accountDetails": "Account Details",
"editPersonalDesc": "Edit Personal Information",
"yourAvatar": "Your Profile Photo",
"avatarHint": "PNG, JPG only",

View file

@ -5,7 +5,26 @@
},
"dashboard": {
"welcomeTitle": "ยินดีต้อนรับกลับ",
"welcomeSubtitle": "วันนี้เป็นวันที่ดีสำหรับการเรียนรู้สิ่งใหม่ๆ มาเก็บความรู้เพิ่มกันเถอะ"
"welcomeSubtitle": "วันนี้เป็นวันที่ดีสำหรับการเรียนรู้สิ่งใหม่ๆ มาเก็บความรู้เพิ่มกันเถอะ",
"heroTitle": "อัปสกิลของคุณต่อเนื่อง",
"heroSubtitle": "เพื่อเป้าหมายที่วางไว้",
"heroDesc": "วันนี้คุณเรียนไปกี่นาทีแล้ว? มาสร้างนิสัยการเรียนรู้ที่ยอดเยี่ยมกันเถอะ เรามีคอร์สแนะนำใหม่ๆ มากมายรอคุณอยู่",
"goToMyCourses": "ไปที่คอร์สเรียนของฉัน",
"searchNewCourses": "ค้นหาคอร์สใหม่",
"continueLearningTitle": "เรียนต่อกับคอร์สของคุณ",
"myCourses": "คอร์สเรียนของฉัน",
"studyAgain": "เรียนอีกครั้ง",
"continue": "เรียนต่อ",
"startNewCourse": "เริ่มเรียนคอร์สใหม่ๆ เพื่อเติมเต็มส่วนนี้",
"knowledgeLibrary": "คลังความรู้",
"libraryDesc": "คุณสามารถเลือกเรียนคอร์สเรียนที่คุณเป็นเจ้าของ",
"chooseLibrary": "เลือกเรียนคอร์สในคลังความรู้ของคุณ",
"viewAll": "ดูทั้งหมด",
"emptyLibraryTitle": "ยังไม่มีคอร์สเรียนในคลัง",
"emptyLibraryDesc": "เริ่มเรียนรู้สิ่งใหม่ๆ วันนี้ เลือกดูคอร์สเรียนที่น่าสนใจเพื่อพัฒนาทักษะของคุณ",
"viewAllCourses": "ดูคอร์สเรียนทั้งหมด",
"recommendedCourses": "คอร์สแนะนำ",
"noRecommended": "ไม่พบข้อมูลคอร์สแนะนำ"
},
"menu": {
"continueLearning": "เรียนต่อจากเดิม",
@ -40,19 +59,29 @@
"studyAgain": "ทบทวนบทเรียน",
"downloadCertificate": "ดาวน์โหลดประกาศนียบัตร",
"completed": "เรียนจบเรียบร้อย",
"includes": "สิ่งที่รวมอยู่ในคอร์ส",
"fullLifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"accessOnMobile": "เรียนได้บนมือถือและแท็บเล็ต",
"lifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"unlimitedQuizzes": "ทำแบบทดสอบไม่จำกัด",
"satisfactionGuarantee": "รับประกันความพึงพอใจ คืนเงินภายใน 7 วัน",
"noContent": "ยังไม่มีเนื้อหาในขณะนี้",
"buyNow": "ซื้อคอร์สเรียนนี้",
"enrollFree": "ลงทะเบียนเรียนฟรี",
"loginToEnroll": "เข้าสู่ระบบเพื่อลงทะเบียน",
"minutes": "นาที",
"noVideoPreview": "วิดีโอตัวอย่างยังไม่พร้อมใช้งาน",
"videoNotSupported": "เบราว์เซอร์ของคุณไม่รองรับการเล่นวิดีโอ"
"videoNotSupported": "เบราว์เซอร์ของคุณไม่รองรับการเล่นวิดีโอ",
"aboutCourse": "เกี่ยวกับคอร์ส",
"lessonDetails": "รายละเอียดบทเรียน",
"courseStats": {
"level": "ระดับ",
"duration": "ระยะเวลา",
"lessons": "บทเรียน",
"students": "ผู้เรียน"
},
"certificatePreview": "ตัวอย่างใบประกาศนียบัตร",
"certificateDesc": "เมื่อเรียนจบและสอบผ่านตามเกณฑ์ที่กำหนด",
"includes": "สิ่งที่รวมอยู่ในคอร์ส",
"fullLifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"accessOnMobile": "เรียนได้บนมือถือและแท็บเล็ต",
"buyNow": "ซื้อคอร์สนี้"
},
"sidebar": {
"overview": "หน้าหลัก",
@ -76,7 +105,9 @@
"showAll": "แสดงทั้งหมด",
"loadMore": "โหลดเพิ่มเติม",
"backToCatalog": "กลับหน้ารายการคอร์ส",
"selectable": "รายการที่เลือก"
"selectable": "รายการที่เลือก",
"foundTotal": "พบทั้งหมด",
"items": "รายการ"
},
"myCourses": {
"filterAll": "ทั้งหมด",
@ -109,6 +140,8 @@
"email": "อีเมล",
"phone": "เบอร์โทรศัพท์",
"joinedAt": "สมัครสมาชิกเมื่อ",
"generalInfo": "ข้อมูลทั่วไป",
"accountDetails": "รายละเอียดบัญชี",
"editPersonalDesc": "แก้ไขข้อมูลส่วนตัว",
"yourAvatar": "รูปโปรไฟล์ของคุณ",
"avatarHint": "เฉพาะไฟล์ png , jpg",

View file

@ -33,16 +33,16 @@ const currentPage = ref(1);
const totalPages = ref(1);
const itemsPerPage = 12;
const { t, locale } = useI18n();
const { currentUser } = useAuth();
const $q = useQuasar();
const { fetchCategories } = useCategory();
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse();
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } =
useCourse();
// 2. Computed Properties
const sortOption = ref(t('discovery.sortRecent'));
const sortOptions = computed(() => [t('discovery.sortRecent')]);
const sortOption = ref(t("discovery.sortRecent"));
const sortOptions = computed(() => [t("discovery.sortRecent")]);
const filteredCourses = computed(() => {
let result = courses.value;
@ -50,12 +50,14 @@ const filteredCourses = computed(() => {
// 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));
result = result.filter((c) =>
selectedCategoryIds.value.includes(c.category_id),
);
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(c => {
result = result.filter((c) => {
const title = getLocalizedText(c.title).toLowerCase();
const desc = getLocalizedText(c.description).toLowerCase();
return title.includes(query) || (desc && desc.includes(query));
@ -66,7 +68,6 @@ const filteredCourses = computed(() => {
// 3. Helper Functions
// 4. API Actions
const loadCategories = async () => {
const res = await fetchCategories();
@ -77,13 +78,16 @@ const loadCourses = async (page = 1) => {
isLoading.value = true;
// Use server-side filtering if exactly one category is selected
const categoryId = selectedCategoryIds.value.length === 1 ? selectedCategoryIds.value[0] : undefined;
const categoryId =
selectedCategoryIds.value.length === 1
? selectedCategoryIds.value[0]
: undefined;
const res = await fetchCourses({
category_id: categoryId,
page: page,
limit: itemsPerPage,
forceRefresh: true
forceRefresh: true,
});
if (res.success) {
@ -108,33 +112,37 @@ const handleEnroll = async (id: number) => {
isEnrolling.value = true;
const res = await enrollCourse(id);
if (res.success) {
return navigateTo('/dashboard/my-courses?enrolled=true');
return navigateTo("/dashboard/my-courses?enrolled=true");
} else {
$q.notify({
type: 'negative',
message: res.error || t('enrollment.error'),
position: 'top',
type: "negative",
message: res.error || t("enrollment.error"),
position: "top",
timeout: 3000,
actions: [{ icon: 'close', color: 'white' }]
})
actions: [{ icon: "close", color: "white" }],
});
}
isEnrolling.value = false;
};
// Watch for category selection changes to reload courses
watch(selectedCategoryIds, () => {
watch(
selectedCategoryIds,
() => {
currentPage.value = 1;
loadCourses(1);
}, { deep: true });
},
{ deep: true },
);
const toggleCategory = (id: number) => {
const index = selectedCategoryIds.value.indexOf(id)
const index = selectedCategoryIds.value.indexOf(id);
if (index === -1) {
selectedCategoryIds.value.push(id)
selectedCategoryIds.value.push(id);
} else {
selectedCategoryIds.value.splice(index, 1)
selectedCategoryIds.value.splice(index, 1);
}
}
};
onMounted(() => {
loadCategories();
@ -144,32 +152,47 @@ onMounted(() => {
<template>
<div class="page-container">
<!-- CATALOG VIEW: Browse courses -->
<div v-if="!showDetail">
<!-- Top Header Area -->
<div class="flex flex-col gap-6 mb-10">
<div class="flex items-start gap-4 mb-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>
<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('discovery.title') }}
<h1
class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight"
>
{{ $t("discovery.title") }}
</h1>
<p v-if="filteredCourses.length > 0" class="text-slate-500 dark:text-slate-400 mt-1 font-medium">
พบทงหมด <span class="text-blue-600 font-bold leading-none">{{ filteredCourses.length }}</span> รายการ
<p
v-if="filteredCourses.length > 0"
class="text-slate-500 dark:text-slate-400 mt-1 font-medium"
>
{{ $t("discovery.foundTotal") }}
<span class="text-blue-600 font-bold leading-none">{{
filteredCourses.length
}}</span>
{{ $t("discovery.items") }}
</p>
</div>
</div>
<!-- Unified Filter Section: Categories -->
<div 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">
<div
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
rounded
dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
:class="selectedCategoryIds.length === 0 ? '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'"
:class="
selectedCategoryIds.length === 0
? '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="selectedCategoryIds = []"
:label="$t('discovery.showAll')"
/>
@ -180,7 +203,11 @@ onMounted(() => {
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'"
: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)"
/>
@ -190,7 +217,9 @@ onMounted(() => {
<!-- Main Layout: Grid Only -->
<div class="w-full">
<div v-if="filteredCourses.length > 0" class="flex flex-col gap-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
>
<CourseCard
v-for="course in filteredCourses"
:key="course.id"
@ -222,17 +251,28 @@ onMounted(() => {
v-else
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"
>
<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>
<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') }}
{{ $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') }}
<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") }}
</button>
</div>
</div>
</div>
<!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
@ -241,8 +281,12 @@ onMounted(() => {
@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') }}
<q-icon
name="arrow_back"
size="24px"
class="transition-transform group-hover:-translate-x-1"
/>
{{ $t("discovery.backToCatalog") }}
</button>
<div v-if="isLoadingDetail" class="flex justify-center py-20">
@ -285,4 +329,3 @@ onMounted(() => {
box-shadow: none !important;
}
</style>

View file

@ -90,6 +90,7 @@ const isPlaying = ref(false)
const videoProgress = ref(0)
const currentTime = ref(0)
const duration = ref(0)
const activeTab = ref('content')
@ -604,60 +605,67 @@ onBeforeUnmount(() => {
</script>
<template>
<q-layout view="hHh LpR lFf" class="bg-[var(--bg-body)] text-[var(--text-main)]">
<q-layout view="hHh lpR lFf" class="bg-[var(--bg-body)] text-[var(--text-main)]">
<!-- Header -->
<q-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-14">
<q-toolbar>
<!-- Exit/Back Button -->
<q-btn
flat
rounded
no-caps
color="primary"
class="mr-4 bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-bold hover:bg-slate-200"
@click="handleExit('/dashboard/my-courses')"
>
<q-icon name="close" size="18px" class="mr-1.5" />
<span class="hidden sm:inline">{{ $t('common.close') }}</span>
<q-tooltip>{{ $t('classroom.backToDashboard') }}</q-tooltip>
</q-btn>
<!-- Sidebar Toggle (Clearer for Course Content) -->
<q-btn
flat
rounded
no-caps
class="mr-2 text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors font-bold px-3"
@click="toggleSidebar"
>
<q-icon name="format_list_bulleted" size="18px" class="mr-1.5" />
<span class="hidden md:inline">{{ $t('classroom.curriculum') }}</span>
</q-btn>
<q-toolbar-title class="text-base font-bold text-left truncate text-slate-900 dark:text-white">
{{ courseData ? getLocalizedText(courseData.course.title) : $t('classroom.loadingTitle') }}
</q-toolbar-title>
<div class="flex items-center gap-2 pr-2">
<!-- Announcements Button -->
<q-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-16">
<q-toolbar class="h-full px-4">
<!-- 1. Left Side: Back & Title -->
<div class="flex items-center gap-4 flex-grow overflow-hidden">
<!-- Back Button -->
<q-btn
flat
round
dense
icon="campaign"
@click="handleOpenAnnouncements"
class="text-slate-600 dark:text-slate-300 hover:text-blue-600 transition-colors"
color="primary"
class="bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
@click="handleExit('/dashboard/my-courses')"
>
<q-badge v-if="hasUnreadAnnouncements" color="red" floating rounded />
<q-icon name="arrow_back" size="20px" />
<q-tooltip>{{ $t('classroom.backToDashboard') }}</q-tooltip>
</q-btn>
<!-- Course Title -->
<div class="flex flex-col">
<h1 class="text-base md:text-lg font-bold text-slate-900 dark:text-white truncate max-w-[200px] md:max-w-md leading-tight">
{{ courseData ? getLocalizedText(courseData.course.title) : $t('classroom.loadingTitle') }}
</h1>
</div>
</div>
<!-- 2. Right Side: Actions -->
<div class="flex items-center gap-3">
<!-- Sidebar Toggle (Right Side) -->
<q-btn
flat
round
dense
class="text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
@click="toggleSidebar"
>
<q-icon name="menu_open" size="24px" class="transform rotate-180" />
<q-tooltip>{{ $t('classroom.curriculum') }}</q-tooltip>
</q-btn>
<!-- Announcements Button (Refined) -->
<q-btn
flat
round
dense
class="bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-slate-700 transition-all relative overflow-visible"
@click="handleOpenAnnouncements"
>
<q-icon name="campaign" size="22px" />
<!-- Red Dot Notification -->
<span v-if="hasUnreadAnnouncements" class="absolute top-2 right-2 w-2.5 h-2.5 bg-rose-500 border-2 border-white dark:border-slate-900 rounded-full"></span>
<q-tooltip>{{ $t('classroom.announcements') }}</q-tooltip>
</q-btn>
</div>
</q-toolbar>
</q-header>
<!-- Sidebar (Curriculum) -->
<!-- Sidebar (Curriculum) -->
<!-- Sidebar (Curriculum) - Positioned Right via component prop -->
<CurriculumSidebar
v-model="sidebarOpen"
:courseData="courseData"
@ -672,7 +680,7 @@ onBeforeUnmount(() => {
<q-page-container class="bg-white dark:bg-slate-900">
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]">
<!-- Video Player & Content Area -->
<div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow">
<div class="w-full h-full p-4 md:p-6 flex-grow overflow-y-auto">
<!-- 1. LOADING STATE (Comprehensive Skeleton) -->
<div v-if="isLessonLoading" class="animate-fade-in">
<!-- Video Skeleton -->

View file

@ -5,53 +5,57 @@
*/
definePageMeta({
layout: 'default',
middleware: 'auth'
})
layout: "default",
middleware: "auth",
});
useHead({
title: 'Dashboard - FutureSkill Clone'
})
title: "Dashboard - FutureSkill Clone",
});
const { currentUser } = useAuth()
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse()
const { fetchCategories } = useCategory()
const { t } = useI18n()
const { currentUser } = useAuth();
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse();
const { fetchCategories } = useCategory();
const { t } = useI18n();
// State
const enrolledCourses = ref<any[]>([])
const recommendedCourses = ref<any[]>([])
const libraryCourses = ref<any[]>([])
const categories = ref<any[]>([])
const enrolledCourses = ref<any[]>([]);
const recommendedCourses = ref<any[]>([]);
const libraryCourses = ref<any[]>([]);
const categories = ref<any[]>([]);
const isLoading = ref(true)
const isLoading = ref(true);
// Initial Data Fetch
onMounted(async () => {
isLoading.value = true
isLoading.value = true;
try {
const [catRes, enrollRes, courseRes] = await Promise.all([
fetchCategories(),
fetchEnrolledCourses({ limit: 10 }), // Fetch more enrolled courses for library section
fetchCourses({ limit: 3, random: true, forceRefresh: true, is_recommended: true }) // Fetch 3 Recommended Courses
])
fetchCourses({
limit: 3,
random: true,
forceRefresh: true,
is_recommended: true,
}), // Fetch 3 Recommended Courses
]);
if (catRes.success) {
categories.value = catRes.data || []
categories.value = catRes.data || [];
}
const catMap = new Map()
categories.value.forEach((c: any) => catMap.set(c.id, c.name))
const catMap = new Map();
categories.value.forEach((c: any) => catMap.set(c.id, c.name));
// Map Enrolled Courses
if (enrollRes.success && enrollRes.data) {
// Sort by last_accessed_at descending (Newest first)
const sortedEnrollments = [...enrollRes.data].sort((a, b) => {
const dateA = new Date(a.last_accessed_at || a.enrolled_at).getTime()
const dateB = new Date(b.last_accessed_at || b.enrolled_at).getTime()
return dateB - dateA
})
const dateA = new Date(a.last_accessed_at || a.enrolled_at).getTime();
const dateB = new Date(b.last_accessed_at || b.enrolled_at).getTime();
return dateB - dateA;
});
enrolledCourses.value = sortedEnrollments.map((item: any) => ({
id: item.course_id,
@ -59,16 +63,18 @@ onMounted(async () => {
thumbnail_url: item.course.thumbnail_url,
progress: item.progress_percentage || 0,
total_lessons: item.course.total_lessons || 10,
completed_lessons: Math.floor((item.progress_percentage / 100) * (item.course.total_lessons || 10)),
completed_lessons: Math.floor(
(item.progress_percentage / 100) * (item.course.total_lessons || 10),
),
// For CourseCard compatibility in library section
category: catMap.get(item.course.category_id),
lessons: item.course.total_lessons || 0,
image: item.course.thumbnail_url,
enrolled: true
}))
enrolled: true,
}));
// Update libraryCourses with only 2 courses
libraryCourses.value = enrolledCourses.value.slice(0, 2)
libraryCourses.value = enrolledCourses.value.slice(0, 2);
}
// Map Recommended Courses
@ -79,48 +85,53 @@ onMounted(async () => {
category: catMap.get(c.category_id),
description: c.description,
lessons: c.total_lessons || 0,
image: c.thumbnail_url || '',
image: c.thumbnail_url || "",
rating: c.rating,
price: c.price,
is_free: c.is_free
}))
is_free: c.is_free,
}));
}
} catch (err) {
console.error('Failed to load dashboard data', err)
console.error("Failed to load dashboard data", err);
} finally {
isLoading.value = false
isLoading.value = false;
}
})
});
// Helper for "Continue Learning" Hero Card
const heroCourse = computed(() => enrolledCourses.value[0] || null)
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
const heroCourse = computed(() => enrolledCourses.value[0] || null);
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
</script>
<template>
<div class="bg-[#F8F9FA] min-h-screen font-inter pb-20">
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen font-inter pb-20 transition-colors duration-300">
<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 rounded-[2rem] py-10 md:py-14 px-8 md:px-12 shadow-sm border border-slate-100 flex flex-col items-center text-center">
<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 rounded-full blur-3xl -z-10" />
<div class="absolute bottom-[-20%] right-[-10%] w-[300px] h-[300px] bg-indigo-500/5 rounded-full blur-3xl -z-10" />
<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">
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 leading-[1.5] tracking-tight">
ปสกลของคณตอเนอง
<span class="inline-block text-blue-600 mt-1 md:mt-2">เพอเปาหมายทวางไว</span>
<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>
<p class="text-slate-500 font-medium text-base md:text-lg max-w-xl mx-auto leading-relaxed">
นนณเรยนไปกนาทแล? มาสรางนยการเรยนรยอดเยยมกนเถอะ เรามคอรสแนะนำใหม มากมายรอคณอย
<p
class="text-slate-500 dark:text-slate-400 font-medium text-base md:text-lg max-w-xl mx-auto leading-relaxed"
>
{{ $t("dashboard.heroDesc") }}
</p>
<div class="flex flex-wrap justify-center gap-4 pt-4">
@ -128,7 +139,7 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
unelevated
rounded
color="primary"
label="ไปที่คอร์สเรียนของฉัน"
:label="$t('dashboard.goToMyCourses')"
class="px-8 h-[48px] font-bold no-caps shadow-lg shadow-blue-500/10 hover:-translate-y-0.5 transition-all text-sm"
to="/dashboard/my-courses"
/>
@ -136,9 +147,9 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
outline
rounded
color="primary"
label="ค้นหาคอร์สใหม่"
class="px-8 h-[48px] font-bold no-caps hover:bg-white transition-all border-1 text-sm"
style="border-width: 1.5px;"
:label="$t('dashboard.searchNewCourses')"
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"
style="border-width: 1.5px"
to="/browse/discovery"
/>
</div>
@ -148,36 +159,73 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
<!-- 2. Continue Learning Section -->
<section v-if="enrolledCourses.length > 0">
<div class="flex justify-between items-end mb-6">
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D]">เรยนตอกบคอรสของค</h2>
<NuxtLink to="/dashboard/my-courses" class="text-blue-600 hover:text-blue-700 font-medium text-sm flex items-center gap-1">
คอรสเรยนของฉ <q-icon name="arrow_forward" size="16px" />
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white transition-colors">
{{ $t("dashboard.continueLearningTitle") }}
</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>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Hero Card (Left) -->
<div v-if="heroCourse"
class="relative group cursor-pointer rounded-2xl overflow-hidden bg-white shadow-sm border border-gray-100 hover:shadow-md transition-all h-[320px]"
@click="navigateTo(`/classroom/learning?course_id=${heroCourse.id}`)">
<img :src="heroCourse.thumbnail_url" class="w-full h-full object-cover brightness-75 group-hover:brightness-90 transition-all duration-500" />
<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]"
@click="
navigateTo(`/classroom/learning?course_id=${heroCourse.id}`)
"
>
<img
:src="heroCourse.thumbnail_url"
class="w-full h-full object-cover brightness-75 group-hover:brightness-90 transition-all duration-500"
/>
<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">{{ getLocalizedText(heroCourse.title) }}</h3>
<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
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 ? 'เรียนอีกครั้ง' : 'เรียนต่อ' }}
<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>
@ -186,32 +234,66 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
<!-- Side List (Right) -->
<div class="flex flex-col gap-4 h-[320px]">
<div v-for="course in sideCourses" :key="course.id" class="flex-1 bg-white rounded-2xl p-4 border border-gray-100 shadow-sm hover:shadow-md transition-all flex gap-4 items-center">
<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" />
<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 font-bold text-sm line-clamp-2 mb-2">{{ getLocalizedText(course.title) }}</h4>
<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 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
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' : 'text-blue-600'"
@click="navigateTo(`/classroom/learning?course_id=${course.id}`)">
{{ course.progress === 100 ? 'เรียนอีกครั้ง' : 'เรียนต่อ' }}
<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 rounded-2xl border border-dashed border-gray-200 flex items-center justify-center text-gray-400 text-sm">
เรมเรยนคอรสใหม เพอเตมเตมสวนน
<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>
@ -220,12 +302,19 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
<!-- 3. Knowledge Library -->
<section>
<div class="mb-6">
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] mb-1">คลงความร</h2>
<p class="text-gray-500 text-sm">ณสามารถเลอกเรยนคอรสเรยนทณเปนเจาของ</p>
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white mb-1 transition-colors">
{{ $t("dashboard.knowledgeLibrary") }}
</h2>
<p class="text-gray-500 dark:text-slate-400 text-sm transition-colors">
{{ $t("dashboard.libraryDesc") }}
</p>
</div>
<!-- Content when courses exist -->
<div v-if="libraryCourses.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
v-if="libraryCourses.length > 0"
class="grid grid-cols-1 md:grid-cols-3 gap-6"
>
<!-- Course Cards -->
<CourseCard
v-for="course in libraryCourses"
@ -238,27 +327,39 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
/>
<!-- CTA Card (Large) -->
<div class="bg-white rounded-3xl border border-gray-100 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 font-medium mb-6 mt-4">เลอกเรยนคอรสในคลงความรของค</p>
<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 px-6 py-2 font-bold group-hover:scale-105 transition-transform"
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"
>
งหมด <q-icon name="arrow_forward" size="18px" class="ml-2" />
{{ $t("dashboard.viewAll") }}
<q-icon name="arrow_forward" size="18px" class="ml-2" />
</q-btn>
</div>
</div>
<!-- Empty State when no courses -->
<div v-else class="bg-white rounded-3xl border border-dashed border-gray-200 p-12 flex flex-col items-center justify-center text-center min-h-[300px]">
<div class="bg-blue-50 p-6 rounded-full mb-6">
<q-icon name="school" size="48px" class="text-blue-200" />
<div
v-else
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 mb-2">งไมคอรสเรยนในคล</h3>
<p class="text-gray-500 mb-8 max-w-md">เรมเรยนรงใหม นน เลอกดคอรสเรยนทาสนใจเพอพฒนาทกษะของค</p>
<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
@ -266,21 +367,23 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
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] text-left">คอรสแนะนำ</h2>
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white text-left transition-colors">
{{ $t("dashboard.recommendedCourses") }}
</h2>
</div>
<!-- Recommended Grid (3 columns) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in">
<div
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"
@ -289,11 +392,13 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
</div>
<!-- Loading State -->
<div v-if="recommendedCourses.length === 0 && !isLoading" class="flex justify-center py-10 opacity-50">
<div class="text-gray-400">ไมพบขอมลคอรสแนะนำ</div>
<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>
</section>
</div>
</div>
</template>
@ -304,8 +409,14 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:deep(.q-btn) {

View file

@ -203,7 +203,7 @@ onMounted(async () => {
</script>
<template>
<div class="page-container">
<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">
@ -212,7 +212,7 @@ onMounted(async () => {
flat
round
icon="arrow_back"
class="dark:text-white"
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">
@ -231,7 +231,7 @@ onMounted(async () => {
unelevated
rounded
color="primary"
class="font-bold"
class="font-bold shadow-lg shadow-blue-500/20"
icon="edit"
:label="$t('profile.editProfile')"
@click="toggleEdit(true)"
@ -247,11 +247,12 @@ onMounted(async () => {
<q-spinner size="3rem" color="primary" />
</div>
<div v-else class="max-w-4xl mx-auto">
<!-- Unified Premium Container -->
<div class="card-premium overflow-hidden fade-in min-h-[600px] flex flex-col">
<div v-else class="max-w-4xl mx-auto pb-20">
<!-- Part 1: Identity Header (Banner & Avatar) -->
<!-- VIEW MODE: Premium Card with Banner -->
<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">
<!-- Identity Header (Banner & Avatar) -->
<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 -->
@ -261,14 +262,14 @@ onMounted(async () => {
</div>
</div>
<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 border-b border-slate-100 dark:border-white/5 relative z-10">
<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">
<UserAvatar
:photo-u-r-l="userData.photoURL"
:first-name="userData.firstName"
:last-name="userData.lastName"
size="140"
class="border-[6px] border-white dark:border-slate-800 shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-900"
class="border-[6px] border-white dark:border-[#1e293b] shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-800 transition-colors duration-300"
/>
</div>
@ -277,75 +278,74 @@ onMounted(async () => {
{{ 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-white/5 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-white/5">
<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-white/5 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-white/5">
<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 ? 'ยืนยันแล้ว' : 'รอการยืนยัน' }}</span>
<span class="text-sm">{{ userData.emailVerifiedAt ? $t('profile.emailVerified') : $t('profile.verifyEmail') }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Part 2: Interactive Controls & Content -->
<!-- View Details Content -->
<div class="p-8 md:p-12 flex-grow">
<!-- Tab Selector (Segmented Pill) -->
<div v-if="isEditing" class="flex justify-center mb-12">
<div class="bg-slate-100 dark:bg-slate-800/50 p-1.5 rounded-2xl flex items-center gap-1 border border-slate-200 dark:border-white/5 shadow-inner">
<button
@click="activeTab = 'general'"
class="px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
:class="activeTab === 'general' ? 'bg-white dark:bg-slate-700 text-blue-600 shadow-md scale-100' : 'text-slate-500 hover:text-slate-700 dark:hover:text-white scale-95 opacity-70'"
>
<q-icon name="person_outline" size="18px" /> อมลทวไป
</button>
<button
@click="activeTab = 'security'"
class="px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
:class="activeTab === 'security' ? 'bg-white dark:bg-slate-700 text-amber-600 shadow-md scale-100' : 'text-slate-500 hover:text-slate-700 dark:hover:text-white scale-95 opacity-70'"
>
<q-icon name="lock_open" size="18px" /> ความปลอดภ
</button>
</div>
</div>
<!-- Content Switching -->
<div class="max-w-3xl mx-auto h-full">
<template v-if="!isEditing">
<div class="fade-in">
<h3 class="text-sm font-black text-slate-400 uppercase tracking-widest flex items-center gap-2 mb-8">
<span class="w-2 h-2 bg-blue-600 rounded-full"></span> รายละเอยดบญช
<div class="max-w-3xl mx-auto h-full fade-in">
<h3 class="text-sm font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest flex items-center gap-2 mb-8">
<span class="w-2 h-2 bg-blue-600 rounded-full"></span> {{ $t('profile.accountDetails') }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
<div class="flex items-center gap-4 group">
<div class="w-12 h-12 rounded-2xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 group-hover:scale-110 transition-transform">
<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">
<q-icon name="smartphone" size="24px" />
</div>
<div>
<div class="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-0.5">เบอรโทรศพท</div>
<div class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-0.5">{{ $t('profile.phone') }}</div>
<div class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">{{ userData.phone || '-' }}</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 group-hover:scale-110 transition-transform">
<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 class="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-0.5">นทเรมเปนสมาช</div>
<div class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-0.5">{{ $t('profile.joinedAt') }}</div>
<div class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">{{ formatDate(userData.createdAt) }}</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<template v-else>
<div class="fade-in">
<div v-if="activeTab === 'general'">
<!-- EDIT MODE: Tabs and Forms (Clean Layout) -->
<div v-else class="fade-in">
<!-- Tab Selector -->
<div class="flex justify-center mb-8">
<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">
<button
@click="activeTab = 'general'"
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 === '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-icon name="person_outline" size="18px" /> {{ $t('profile.generalInfo') }}
</button>
<button
@click="activeTab = 'security'"
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'"
>
<q-icon name="lock_open" size="18px" /> {{ $t('profile.security') }}
</button>
</div>
</div>
<!-- Edit Content -->
<div class="max-w-3xl mx-auto">
<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">
<ProfileEditForm
v-model="userData"
:loading="isProfileSaving"
@ -353,22 +353,18 @@ onMounted(async () => {
@submit="handleUpdateProfile"
@upload="handleFileUpload"
@verify="handleSendVerifyEmail"
flat
/>
</div>
<div v-else>
<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">
<PasswordChangeForm
v-model="passwordForm"
:loading="isPasswordSaving"
@submit="handleUpdatePassword"
flat
/>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
@ -379,57 +375,7 @@ onMounted(async () => {
color: white;
}
.card-premium {
background-color: white;
border-color: #e2e8f0;
border-radius: 1.5rem;
border-width: 1px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.dark .card-premium {
background-color: #1e293b;
border-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.3);
}
.info-group .label {
font-size: 0.75rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #64748b;
display: block;
margin-bottom: 0.5rem;
}
.dark .info-group .label {
color: #94a3b8;
}
.info-group .value {
font-size: 1.125rem;
font-weight: 700;
color: #0f172a;
}
.dark .info-group .value {
color: white;
}
.premium-q-input :deep(.q-field__control) {
border-radius: 12px;
}
.dark .premium-q-input :deep(.q-field__control) {
background: #0f172a;
}
.dark .premium-q-input :deep(.q-field__native) {
color: white;
}
.dark .premium-q-input :deep(.q-field__label) {
color: #94a3b8;
}
/* Removed card-premium and dark mode overrides as we used utility classes */
.fade-in {
animation: fadeIn 0.4s ease-out forwards;

View file

@ -33,3 +33,39 @@
- แก้ไขไฟล์ `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

@ -4,7 +4,7 @@
---
## 🏗 1. Technical Foundation (รากฐานทางเทคนิค)
## z 1. Technical Foundation (รากฐานทางเทคนิค)
รวมข้อมูลเครื่องมือ, ระบบความปลอดภัย และประสิทธิภาพการทำงานไว้ด้วยกัน