feat: Implement core application UI with new headers, navigation, and initial pages.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 42s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s

This commit is contained in:
supalerk-ar66 2026-02-19 10:39:44 +07:00
parent 3fa236cff5
commit 1b9119e606
10 changed files with 345 additions and 190 deletions

View file

@ -19,16 +19,15 @@ const searchText = ref('')
</script> </script>
<template> <template>
<q-toolbar class="bg-transparent text-slate-800 dark:text-white h-16 px-4"> <q-toolbar class="bg-white text-slate-900 h-16 px-4 md:px-8">
<!-- Menu Toggle (Always Visible) --> <!-- Mobile Menu Toggle -->
<q-btn <q-btn
flat flat
round round
dense dense
icon="menu" icon="menu"
@click="emit('toggleSidebar')" class="lg:hidden mr-2 text-gray-500"
class="mr-2 text-slate-900 dark:text-white bg-slate-100 dark:bg-slate-700/50 hover:bg-slate-200 dark:hover:bg-slate-600" @click="$emit('toggleSidebar')"
aria-label="Menu"
/> />
<!-- Branding --> <!-- Branding -->
@ -37,49 +36,53 @@ const searchText = ref('')
E E
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="font-black text-lg leading-none tracking-tight text-slate-900 dark:text-white group-hover:text-blue-600 transition-colors">E-Learning</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 dark:text-slate-400">Platform</span> <span class="text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500">Platform</span>
</div> </div>
</div> </div>
<!-- Desktop Navigation --> <!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-6 ml-8 text-sm font-bold"> <nav class="hidden lg:flex items-center gap-6 text-sm font-medium text-gray-600">
<NuxtLink to="/browse" class="text-slate-600 hover:text-blue-600 transition-colors"> <div class="cursor-pointer hover:text-purple-600 flex items-center gap-1 transition-colors">
{{ $t('sidebar.onlineCourses') }} คอรสออนไลน <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> </NuxtLink>
<NuxtLink to="/browse/discovery" class="text-slate-600 hover:text-blue-600 transition-colors"> <div class="cursor-pointer hover:text-purple-600 transition-colors">บทความ</div>
{{ $t('sidebar.recommendedCourses') }} <div class="cursor-pointer hover:text-purple-600 transition-colors">สมาชกรายป</div>
</NuxtLink> <div class="cursor-pointer hover:text-purple-600 transition-colors">สำหรบองคกร</div>
</nav> </nav>
<q-space />
<!-- Center Search (Optional) -->
<div v-if="showSearch !== false" class="hidden md:block w-1/3 max-w-md mx-4">
<q-input
dense
outlined
rounded
v-model="searchText"
:placeholder="$t('menu.searchCourses')"
class="bg-slate-50 dark:bg-slate-700/50 search-input"
bg-color="transparent"
>
<template v-slot:prepend>
<q-icon name="search" class="text-slate-400" />
</template>
</q-input>
</div>
<q-space /> <q-space />
<!-- Right Actions --> <!-- Right Actions -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 sm:gap-4 text-gray-500">
<!-- Language Switcher --> <!-- Search Icon -->
<q-btn flat round dense icon="search" class="hover:text-purple-600 transition-colors" />
<!-- Cart Icon -->
<q-btn flat round dense icon="shopping_cart" class="hover:text-purple-600 transition-colors" />
<!-- Notifications -->
<q-btn flat round dense icon="notifications_none" class="hover:text-purple-600 transition-colors">
<q-badge color="red" floating rounded dense v-if="false">2</q-badge>
</q-btn>
<!-- Language -->
<LanguageSwitcher /> <LanguageSwitcher />
<!-- User Profile Dropdown --> <!-- User Profile -->
<UserMenu /> <UserMenu />
</div> </div>
</q-toolbar> </q-toolbar>

View file

@ -9,11 +9,16 @@
const isScrolled = ref(false) const isScrolled = ref(false)
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
const handleScroll = () => {
isScrolled.value = window.scrollY > 20
}
onMounted(() => { onMounted(() => {
// Add scroll listener to toggle 'isScrolled' class window.addEventListener('scroll', handleScroll)
window.addEventListener('scroll', () => { })
isScrolled.value = window.scrollY > 20
}) onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
}) })
</script> </script>

View file

@ -23,13 +23,13 @@ export const useNavItems = () => {
to: '/browse', to: '/browse',
labelKey: 'sidebar.onlineCourses', labelKey: 'sidebar.onlineCourses',
icon: 'video_library', icon: 'video_library',
showOn: ['sidebar', 'mobile', 'userMenu'] showOn: ['mobile']
}, },
{ {
to: '/browse/discovery', to: '/browse/discovery',
labelKey: 'sidebar.recommendedCourses', labelKey: 'sidebar.recommendedCourses',
icon: 'auto_awesome', icon: 'auto_awesome',
showOn: ['sidebar', 'mobile', 'userMenu'] showOn: ['mobile']
}, },
{ {
to: '/browse/discovery', to: '/browse/discovery',
@ -47,13 +47,13 @@ export const useNavItems = () => {
to: '/dashboard/announcements', to: '/dashboard/announcements',
labelKey: 'sidebar.announcements', labelKey: 'sidebar.announcements',
icon: 'campaign', icon: 'campaign',
showOn: ['sidebar', 'mobile', 'userMenu'] showOn: ['mobile']
}, },
{ {
to: '/dashboard/profile', to: '/dashboard/profile',
labelKey: 'sidebar.profile', labelKey: 'sidebar.profile',
icon: 'person', icon: 'person',
showOn: ['sidebar'] // Already in settings for userMenu showOn: [] // Was ['sidebar']
}, },
{ {
to: '/dashboard/profile', to: '/dashboard/profile',

View file

@ -18,8 +18,7 @@ const toggleLeftDrawer = () => {
<q-layout view="hHh LpR lFf" class="bg-slate-50 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
bordered 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 border-b border-slate-200 dark:border-slate-800"
> >
<AppHeader @toggleSidebar="toggleLeftDrawer" /> <AppHeader @toggleSidebar="toggleLeftDrawer" />
</q-header> </q-header>
@ -28,9 +27,8 @@ const toggleLeftDrawer = () => {
<q-drawer <q-drawer
v-model="leftDrawerOpen" v-model="leftDrawerOpen"
show-if-above show-if-above
bordered
:width="280" :width="280"
class="bg-white dark:!bg-[#0f172a] border-r border-slate-200 dark:border-slate-800" class="bg-white dark:!bg-[#0f172a]"
> >
<AppSidebar /> <AppSidebar />
</q-drawer> </q-drawer>
@ -45,8 +43,7 @@ const toggleLeftDrawer = () => {
<!-- Mobile Bottom Nav --> <!-- Mobile Bottom Nav -->
<q-footer <q-footer
v-if="$q.screen.lt.md" v-if="$q.screen.lt.md"
bordered class="!bg-white dark:!bg-[#1e293b] text-primary"
class="!bg-white dark:!bg-[#1e293b] text-primary border-t border-slate-200 dark:border-slate-700"
> >
<MobileNav /> <MobileNav />
</q-footer> </q-footer>

View file

@ -35,11 +35,7 @@ const selectedCategory = ref((route.query.category as string) || 'all')
const selectCategory = (slug: string) => { const selectCategory = (slug: string) => {
if (slug === 'all') { if (slug === 'all') {
// Remove query param for 'all' or keep it? Protocol says /browse?category=all is fine. router.push({ query: { ...route.query, category: 'all' } })
// But usually clean URL is better. Let's keep it simple as requested: "Click programming => /browse?category=programming".
// "all" might just remove the param or be ?category=all. The prompt didn't specify 'all' behavior for URL, but fallback is fine.
// If I use 'all', the watcher sets 'all'.
router.push({ query: { category: 'all' } })
} else { } else {
router.push({ query: { ...route.query, category: slug } }) router.push({ query: { ...route.query, category: slug } })
} }
@ -55,7 +51,7 @@ const categoryLabels: Record<string, string> = {
all: "ทั้งหมด", all: "ทั้งหมด",
programming: "การเขียนโปรแกรม", programming: "การเขียนโปรแกรม",
design: "การออกแบบ", design: "การออกแบบ",
business: "เกี่ยวกับธุรกิจ" business: "ธุรกิจ"
} }
const getCategoryLabel = (category: any) => { const getCategoryLabel = (category: any) => {
@ -68,8 +64,6 @@ const getCategoryLabel = (category: any) => {
// Fetch categories on mount // Fetch categories on mount
await useAsyncData('categories-list', () => fetchCategories()) await useAsyncData('categories-list', () => fetchCategories())
// Fetch courses from API (reactive to selectedCategory)
// Fetch courses from API (reactive to selectedCategory)
// Fetch courses from API (reactive to selectedCategory) // Fetch courses from API (reactive to selectedCategory)
const { data: coursesResponse, error, refresh } = await useAsyncData( const { data: coursesResponse, error, refresh } = await useAsyncData(
'browse-courses-list', 'browse-courses-list',
@ -307,15 +301,16 @@ const filteredCourses = computed(() => {
CTA SECTION CTA SECTION
Call to action to register Call to action to register
========================================== --> ========================================== -->
<section class="py-20 relative overflow-hidden"> <section class="py-24 relative overflow-hidden">
<!-- Gradient Overlay --> <!-- Background Decoration -->
<div class="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent pointer-events-none"/> <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"> <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-8 tracking-tight"> <h2 class="text-4xl md:text-5xl font-black text-slate-900 mb-6 tracking-tight">
พรอมจะเรมตนแลวหรอย? พรอมจะเรมตนแลวหรอย?
</h2> </h2>
<p class="text-slate-400 text-xl mb-12 max-w-2xl mx-auto leading-relaxed"> <p class="text-slate-500 text-lg md:text-xl mb-10 max-w-2xl mx-auto leading-relaxed">
ลงทะเบยนฟรนนเพอเขาถงบทเรยนพนฐาน และตดตามความคบหนาการเรยนของคณไดนท ไมาใชายแอบแฝง ลงทะเบยนฟรนนเพอเขาถงบทเรยนพนฐาน และตดตามความคบหนาการเรยนของคณไดนท ไมาใชายแอบแฝง
</p> </p>
<NuxtLink <NuxtLink

View file

@ -270,15 +270,16 @@ const filteredCourses = computed(() => {
<!-- ========================================== <!-- ==========================================
CTA SECTION CTA SECTION
========================================== --> ========================================== -->
<section class="py-20 relative overflow-hidden"> <section class="py-24 relative overflow-hidden">
<!-- Gradient Overlay --> <!-- Background Decoration -->
<div class="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent pointer-events-none"/> <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"> <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-8 tracking-tight"> <h2 class="text-4xl md:text-5xl font-black text-slate-900 mb-6 tracking-tight">
ปลดลอกศกยภาพของคณวนน ปลดลอกศกยภาพของคณวนน
</h2> </h2>
<p class="text-slate-400 text-xl mb-12 max-w-2xl mx-auto leading-relaxed"> <p class="text-slate-500 text-lg md:text-xl mb-10 max-w-2xl mx-auto leading-relaxed">
เรมเรยนรบคอรสทเราแนะนำ และเตบโตไปพรอมกบผเรยนนบหมนคน เรมเรยนรบคอรสทเราแนะนำ และเตบโตไปพรอมกบผเรยนนบหมนคน
</p> </p>
<NuxtLink <NuxtLink

View file

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file home.vue * @file index.vue
* @description หนาแดชบอรดหล (Dashboard) * @description Dashboard Home Page matching FutureSkill design
* แสดงขอความตอนร และคอรสแนะนำสำหรบผเรยน
*/ */
definePageMeta({ definePageMeta({
@ -11,106 +10,234 @@ definePageMeta({
}) })
useHead({ useHead({
title: 'Dashboard - e-Learning' title: 'Dashboard - FutureSkill Clone'
}) })
const { currentUser } = useAuth() const { currentUser } = useAuth()
const { fetchCourses, getLocalizedText } = useCourse() // Import useCourse const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse()
const { fetchCategories } = useCategory() // Import useCategory const { fetchCategories } = useCategory()
const { t } = useI18n() const { t } = useI18n()
// State
const enrolledCourses = ref<any[]>([])
// Recommended Courses State
// ( 3 )
const recommendedCourses = ref<any[]>([]) const recommendedCourses = ref<any[]>([])
const libraryCourses = ref<any[]>([])
const categories = ref<any[]>([])
const isLoading = ref(true)
// Initial Data Fetch
onMounted(async () => { onMounted(async () => {
// 1. Fetch Categories for mapping isLoading.value = true
const catRes = await fetchCategories() try {
const catMap = new Map() const [catRes, enrollRes, courseRes] = await Promise.all([
if (catRes.success) { fetchCategories(),
catRes.data?.forEach((c: any) => catMap.set(c.id, c.name)) fetchEnrolledCourses({ limit: 3, status: 'IN_PROGRESS' }), // Fetch recent enrolled
} fetchCourses({ limit: 3, random: true, forceRefresh: true, is_recommended: true }) // Fetch 3 Recommended Courses
])
// 2. Fetch 3 Random Courses from Server if (catRes.success) {
// Server ( API parameter random limit) categories.value = catRes.data || []
const res = await fetchCourses({ random: true, limit: 3, forceRefresh: true, is_recommended: true }) }
if (res.success && res.data?.length) { const catMap = new Map()
recommendedCourses.value = res.data.map((c: any) => ({ categories.value.forEach((c: any) => catMap.set(c.id, c.name))
id: c.id,
title: c.title,
category: catMap.get(c.category_id), // Map Enrolled Courses
lessons: c.lessons, if (enrollRes.success && enrollRes.data) {
image: c.thumbnail_url || '', enrolledCourses.value = enrollRes.data.map((item: any) => ({
badge: '', id: item.course_id,
badgeType: '' title: item.course.title,
})) 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))
}))
}
// Map Recommended/Library Courses
if (courseRes.success && courseRes.data) {
// Use fetched courses for recommended section
recommendedCourses.value = courseRes.data.map((c: any) => ({
id: c.id,
title: c.title,
category: catMap.get(c.category_id),
description: c.description,
lessons: c.total_lessons || 0,
image: c.thumbnail_url || '',
rating: c.rating,
price: c.price,
is_free: c.is_free
}))
// Just for demo, use same data for library if needed or fetch separately
libraryCourses.value = courseRes.data.slice(0, 2)
}
} catch (err) {
console.error('Failed to load dashboard data', err)
} finally {
isLoading.value = false
} }
}) })
// Helper for "Continue Learning" Hero Card
const heroCourse = computed(() => enrolledCourses.value[0] || null)
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
</script> </script>
<template> <template>
<div class="page-container !pt-4"> <div class="bg-[#F8F9FA] min-h-screen font-inter pb-20">
<!-- Welcome Header Section (Minimalist) -->
<div class="flex items-center gap-6 mb-10 py-2 animate-fade-in"> <!-- 1. Greeting Section -->
<!-- Avatar with premium shadow --> <section class="bg-white pt-8 pb-10 px-4 md:px-12">
<div class="relative group cursor-pointer" @click="$router.push('/dashboard/profile')"> <div class="max-w-7xl mx-auto">
<div class="absolute -inset-1 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div> <h1 class="text-3xl md:text-4xl font-bold text-[#2D2D2D] mb-4 flex items-center gap-3">
<q-avatar size="84px" class="relative shadow-2xl ring-4 ring-white dark:ring-slate-900 transition-all duration-500 overflow-hidden"> สวสด {{ currentUser?.firstName || 'User' }} <span class="text-3xl">😊</span>
<img </h1>
:src="currentUser?.photoURL || 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y'" <p class="text-gray-500 text-base md:text-lg font-light max-w-3xl">
class="object-cover" เขาถ +490 หลกสตรออนไลนสำหรบสมาชกรายป เพอบรรลเปาหมายดานอาชพและพฒนาการเรยนรสำหรบค
/> </p>
</q-avatar> </div>
</div> </section>
<div class="flex flex-col">
<h1 class="text-2xl md:text-4xl font-black text-slate-900 dark:text-white tracking-tight leading-tight mb-1">
{{ $t('dashboard.welcomeTitle') }}, {{ currentUser?.firstName }}!
</h1>
<div class="flex items-center gap-2">
<span class="text-slate-500 dark:text-slate-400 text-sm font-medium">{{ $t('dashboard.welcomeSubtitle') }}</span>
</div>
</div>
</div>
<!-- Main Content Area --> <div class="max-w-7xl mx-auto px-4 md:px-12 space-y-16 mt-10">
<div>
<!-- Section: Recommended Courses --> <!-- 2. Continue Learning Section -->
<div class="mb-6"> <section v-if="enrolledCourses.length > 0">
<h2 class="text-xl font-black flex items-center gap-3 tracking-tight text-slate-900 dark:text-white"> <div class="flex justify-between items-end mb-6">
<span class="w-1 h-6 bg-emerald-500 rounded-full shadow-sm shadow-emerald-500/50"/> <h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D]">เรยนตอกบคอรสของค</h2>
{{ $t('menu.recommendedCourses') }} <NuxtLink to="/dashboard/my-courses" class="text-purple-600 hover:text-purple-700 font-medium text-sm flex items-center gap-1">
</h2> การเรยนของฉ <q-icon name="arrow_forward" size="16px" />
</div> </NuxtLink>
</div>
<!-- Recommended Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-20"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CourseCard <!-- Hero Card (Left) -->
v-for="course in recommendedCourses" <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]">
:key="course.id" <img :src="heroCourse.thumbnail_url" class="w-full h-full object-cover brightness-75 group-hover:brightness-90 transition-all duration-500" />
v-bind="course"
/> <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent p-8 flex flex-col justify-end">
</div> <div class="bg-purple-600 text-white text-xs font-bold px-3 py-1 rounded w-fit mb-3">COURSE</div>
<h3 class="text-white text-2xl font-bold mb-4 line-clamp-2 leading-snug">{{ getLocalizedText(heroCourse.title) }}</h3>
<!-- Loading State for Recommended -->
<div v-if="recommendedCourses.length === 0" class="flex justify-center py-10 opacity-50"> <!-- Progress -->
<div class="animate-pulse">Loading recommendations...</div> <div class="w-full">
</div> <div class="flex justify-between text-gray-300 text-xs mb-2">
</div> <span>{{ heroCourse.completed_lessons }}/{{ heroCourse.total_lessons }} บทเรยน</span>
<span>{{ heroCourse.progress }}%</span>
</div>
<div class="h-1.5 w-full bg-white/20 rounded-full overflow-hidden">
<div class="h-full bg-purple-500 rounded-full" :style="{ width: `${heroCourse.progress}%` }"></div>
</div>
<div class="mt-4 flex justify-end">
<span class="text-white font-bold text-sm hover:underline">เรยนต</span>
</div>
</div>
</div>
</div>
<!-- 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 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 font-bold text-sm line-clamp-2 mb-2">{{ 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 bg-purple-600 rounded-full" :style="{ width: `${course.progress}%` }"></div>
</div>
<div class="flex justify-between items-center text-xs">
<span class="text-gray-500">{{ course.completed_lessons }}/{{ course.total_lessons }} บทเรยน</span>
<span class="text-purple-600 font-bold cursor-pointer hover:underline" @click="navigateTo(`/classroom/learning?course_id=${course.id}`)">เรียนต่อ</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>
</div>
</div>
</section>
<!-- 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>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Course Cards -->
<CourseCard
v-for="course in libraryCourses"
:key="course.id"
v-bind="course"
:image="course.thumbnail_url"
class="h-full md:col-span-1"
/>
<!-- 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>
<q-btn
flat
rounded
no-caps
class="text-purple-600 hover:bg-purple-50 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" />
</q-btn>
</div>
</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>
</div>
<!-- Recommended Grid (3 columns) -->
<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"
v-bind="course"
/>
</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>
</section>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
@keyframes fade-in { /* Scoped specific styles */
from { opacity: 0; transform: translateX(-20px); } .animate-fade-in {
to { opacity: 1; transform: translateX(0); } animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
} }
.animate-fade-in { :deep(.q-btn) {
animation: fade-in 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; text-transform: none; /* Prevent uppercase in Q-Btns */
} }
</style> </style>

View file

@ -15,6 +15,35 @@ useHead({
const { fetchCategories } = useCategory() const { fetchCategories } = useCategory()
const { fetchCourses, getLocalizedText } = useCourse() const { fetchCourses, getLocalizedText } = useCourse()
const { user } = useAuth()
const stepOneCards = [
{ 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' }
]
const learningStyles = [
{
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[]>([])
@ -289,7 +318,7 @@ onMounted(() => {
: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'" :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'"
> >
All งหมด
</button> </button>
<button <button
v-for="category in categories" v-for="category in categories"
@ -360,44 +389,7 @@ onMounted(() => {
</div> </div>
</template> </template>
<script lang="ts">
// Supplementary static data for the sections
export default {
setup() {
const { user } = useAuth()
return { user }
},
data() {
return {
stepOneCards: [
{ 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' }
],
learningStyles: [
{
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'
}
],
promoCategories: [
{ title: 'Data', desc: 'เรียนรู้และฝึกฝนกระบวนการคิดสร้างมูลค่าให้ธุรกิจด้วยข้อมูล', icon: 'analytics' },
{ title: 'Design', desc: 'ออกแบบ Digital Product เพื่อให้ผู้ใช้งานได้รับประสบการณ์ที่ดีที่สุด', icon: 'palette' },
{ title: 'Tech', desc: 'พร้อมเป็นที่ต้องการของตลาดแรงงานด้วยทักษะการเขียนโปรแกรม', icon: 'code' },
{ title: 'Business', desc: 'พลิกโฉมธุรกิจในยุคดิจิทัลด้วยการเข้าถึงลูกค้าในช่องทางและเวลาที่เหมาะสม', icon: 'trending_up' }
]
}
}
}
</script>
<style scoped> <style scoped>
.landing-page { .landing-page {

View file

@ -0,0 +1,35 @@
วันที่บันทึกปฏิบัติงาน \*
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)