feat: Implement user profile management, course browsing, and dashboard structure with new components and layouts.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 45s
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-19 17:37:28 +07:00
parent c118e5c3dc
commit 0f92f0d00c
10 changed files with 446 additions and 195 deletions

View file

@ -25,6 +25,8 @@ interface CourseCardProps {
showContinue?: boolean showContinue?: boolean
showCertificate?: boolean showCertificate?: boolean
showStudyAgain?: boolean showStudyAgain?: boolean
hideProgress?: boolean
hideActions?: boolean
} }
const props = withDefaults(defineProps<CourseCardProps>(), { const props = withDefaults(defineProps<CourseCardProps>(), {
@ -106,7 +108,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
<div class="mt-auto pt-4"> <div class="mt-auto pt-4">
<!-- Progress Bar --> <!-- Progress Bar -->
<div v-if="progress !== undefined && !completed" class="mb-4"> <div v-if="progress !== undefined && !completed && !hideProgress" class="mb-4">
<div class="flex justify-between text-[10px] font-bold uppercase mb-1"> <div class="flex justify-between text-[10px] font-bold uppercase mb-1">
<span class="text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span> <span class="text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
<span class="text-blue-600 dark:text-blue-400">{{ progress }}%</span> <span class="text-blue-600 dark:text-blue-400">{{ progress }}%</span>
@ -117,25 +119,27 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<!-- View Details (Secondary Action) --> <div v-if="!hideActions" class="flex flex-col gap-3">
<q-btn <!-- View Details (Secondary Action) -->
v-if="showViewDetails && !completed && !progress" <q-btn
flat v-if="showViewDetails && !completed && !progress"
rounded flat
class="w-full font-bold !text-blue-600 !bg-blue-50 hover:!bg-blue-100 dark:!bg-blue-900/40 dark:!text-blue-300 dark:hover:!bg-blue-900/60" rounded
:label="$t('menu.viewDetails')" class="w-full font-bold !text-blue-600 !bg-blue-50 hover:!bg-blue-100 dark:!bg-blue-900/40 dark:!text-blue-300 dark:hover:!bg-blue-900/60"
:to="`/course/${id}`" :label="$t('menu.viewDetails')"
/> :to="`/course/${id}`"
/>
<!-- Continue Learning (Primary Action) --> <!-- Continue Learning (Primary Action) -->
<q-btn <q-btn
v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)" v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)"
unelevated unelevated
rounded rounded
class="w-full font-bold !bg-blue-600 !text-white hover:!bg-blue-700 shadow-md shadow-blue-500/20 transition-all hover:scale-[1.02]" class="w-full font-bold !bg-blue-600 !text-white hover:!bg-blue-700 shadow-md shadow-blue-500/20 transition-all hover:scale-[1.02]"
:label="(!progress || progress === 0) ? $t('course.startLearning') : $t('course.continueLearning')" :label="(!progress || progress === 0) ? $t('course.startLearning') : $t('course.continueLearning')"
:to="`/classroom/learning?course_id=${id}`" :to="`/classroom/learning?course_id=${id}`"
/> />
</div>
<div v-if="completed" class="space-y-2"> <div v-if="completed" class="space-y-2">
<!-- Study Again --> <!-- Study Again -->

View file

@ -5,9 +5,15 @@
* Uses Quasar QToolbar. * Uses Quasar QToolbar.
*/ */
defineProps<{ import { ref, computed } from 'vue'
const props = defineProps<{
/** Controls visibility of the search bar */ /** Controls visibility of the search bar */
showSearch?: boolean showSearch?: boolean
/** Controls visibility of the sidebar toggle button */
showSidebarToggle?: boolean
/** Type of navigation links to display */
navType?: 'public' | 'learner'
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -15,67 +21,114 @@ const emit = defineEmits<{
toggleSidebar: [] toggleSidebar: []
}>() }>()
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
// 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 searchText = ref('') const searchText = ref('')
</script> </script>
<template> <template>
<q-toolbar class="bg-white text-slate-900 h-16 px-4 md:px-8"> <q-toolbar class="bg-white text-slate-900 h-16 shadow-sm border-none p-0">
<!-- Mobile Menu Toggle --> <div class="container mx-auto w-full px-6 md:px-12 flex items-center h-full">
<q-btn <!-- Mobile Menu Toggle -->
flat <q-btn
round v-if="showSidebarToggle !== false && navTypeComputed !== 'learner'"
dense flat
icon="menu" round
class="lg:hidden mr-2 text-gray-500" dense
@click="$emit('toggleSidebar')" icon="menu"
/> class="lg:hidden mr-2 text-gray-500"
@click="$emit('toggleSidebar')"
/>
<!-- Branding --> <!-- Branding -->
<div class="flex items-center gap-3 cursor-pointer group" @click="navigateTo('/dashboard')"> <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="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 E
</div>
<div class="flex flex-col">
<span class="font-black text-lg leading-none tracking-tight text-slate-900 group-hover:text-blue-600 transition-colors">E-Learning</span>
<span class="text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500">Platform</span>
</div>
</div> </div>
<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> <div class="flex items-center min-w-[200px] md:min-w-[320px] max-w-sm ml-8 mr-8 h-10">
<span class="text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500">Platform</span> <q-input
v-model="searchText"
dense
borderless
placeholder="ค้นหาคอร์สเรียน..."
class="search-input w-full bg-slate-100/60 px-4 rounded-full transition-all duration-300 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-100 h-full flex items-center"
@keyup.enter="navigateTo(`/browse/discovery?search=${searchText}`)"
>
<template #prepend>
<q-icon name="search" size="xs" class="text-blue-600" />
</template>
</q-input>
</div> </div>
</div>
<!-- Desktop Navigation --> <!-- Desktop Navigation -->
<nav class="hidden lg:flex items-center gap-6 text-sm font-medium text-gray-600"> <nav class="flex items-center gap-8 text-[14px] font-bold text-slate-600">
<div class="cursor-pointer hover:text-purple-600 flex items-center gap-1 transition-colors">
คอรสออนไลน <q-icon name="keyboard_arrow_down" /> <!-- Learner Navigation (Dashboard Mode) -->
<q-menu> <template v-if="navTypeComputed === 'learner'">
<q-list dense style="min-width: 150px"> <NuxtLink to="/dashboard" class="hover:text-blue-600 transition-colors uppercase tracking-wider" active-class="text-blue-600">
<q-item clickable v-close-popup to="/browse"> หนาหล
<q-item-section>งหมด</q-item-section> </NuxtLink>
</q-item> <NuxtLink to="/browse/discovery" class="hover:text-blue-600 transition-colors uppercase tracking-wider" active-class="text-blue-600">
</q-list> คอรสเรยนทงหมด
</q-menu> </NuxtLink>
<NuxtLink to="/dashboard/my-courses" class="hover:text-blue-600 transition-colors uppercase tracking-wider" active-class="text-blue-600">
{{ $t('sidebar.myCourses') || 'คอร์สเรียนของฉัน' }}
</NuxtLink>
</template>
<!-- Public Navigation (Default) -->
<template v-else>
<div class="cursor-pointer hover:text-purple-600 flex items-center gap-1 transition-colors">
คอรสเรยนทงหมด <q-icon name="keyboard_arrow_down" />
<q-menu>
<q-list dense style="min-width: 150px">
<q-item clickable v-close-popup to="/browse">
<q-item-section>งหมด</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
<div class="cursor-pointer hover:text-purple-600 flex items-center gap-1 transition-colors">
หลกสตร Onsite <q-icon name="keyboard_arrow_down" />
</div>
<NuxtLink to="/browse/recommended" class="hover:text-purple-600 transition-colors">
คอรสแนะนำ
</NuxtLink>
<div class="cursor-pointer hover:text-purple-600 transition-colors">บทความ</div>
<div class="cursor-pointer hover:text-purple-600 transition-colors">สมาชกรายป</div>
<div class="cursor-pointer hover:text-purple-600 transition-colors">สำหรบองคกร</div>
</template>
</nav>
<q-space />
<!-- Right Actions -->
<div class="flex items-center gap-2 sm:gap-4 text-gray-500">
<!-- Search Icon -->
<!-- Language -->
<LanguageSwitcher />
<!-- User Profile -->
<UserMenu />
</div> </div>
<div class="cursor-pointer hover:text-purple-600 flex items-center gap-1 transition-colors">
หลกสตร Onsite <q-icon name="keyboard_arrow_down" />
</div>
<NuxtLink to="/browse/recommended" class="hover:text-purple-600 transition-colors">
คอรสแนะนำ
</NuxtLink>
<div class="cursor-pointer hover:text-purple-600 transition-colors">บทความ</div>
<div class="cursor-pointer hover:text-purple-600 transition-colors">สมาชกรายป</div>
<div class="cursor-pointer hover:text-purple-600 transition-colors">สำหรบองคกร</div>
</nav>
<q-space />
<!-- Right Actions -->
<div class="flex items-center gap-2 sm:gap-4 text-gray-500">
<!-- Search Icon -->
<!-- Language -->
<LanguageSwitcher />
<!-- User Profile -->
<UserMenu />
</div> </div>
</q-toolbar> </q-toolbar>
</template> </template>

View file

@ -7,6 +7,7 @@
const props = defineProps<{ const props = defineProps<{
modelValue: any; // passwordForm (currentPassword, newPassword, confirmPassword) modelValue: any; // passwordForm (currentPassword, newPassword, confirmPassword)
loading: boolean; loading: boolean;
flat?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -33,11 +34,15 @@ const showConfirmPassword = ref(false);
</script> </script>
<template> <template>
<div class="card-premium p-8 h-fit"> <div :class="[!flat ? 'card-premium p-6 md:p-8' : '']" class="h-fit">
<h2 class="text-xl font-bold flex items-center gap-3 text-slate-900 dark:text-white mb-6"> <div v-if="!flat" class="flex items-center gap-3 mb-8">
<q-icon name="lock" class="text-amber-500 text-2xl" /> <div class="w-10 h-10 rounded-xl bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
{{ $t('profile.security') }} <q-icon name="lock" class="text-amber-600 dark:text-amber-400 text-xl" />
</h2> </div>
<h2 class="text-xl font-black text-slate-900 dark:text-white">
{{ $t('profile.security') }}
</h2>
</div>
<q-form @submit="emit('submit')" class="flex flex-col gap-6"> <q-form @submit="emit('submit')" class="flex flex-col gap-6">
<div class="text-sm text-slate-500 dark:text-slate-400 mb-2"> <div class="text-sm text-slate-500 dark:text-slate-400 mb-2">

View file

@ -8,6 +8,7 @@ const props = defineProps<{
modelValue: any; // userData (firstName, lastName, phone, etc.) modelValue: any; // userData (firstName, lastName, phone, etc.)
loading: boolean; loading: boolean;
verifying?: boolean; verifying?: boolean;
flat?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -67,11 +68,15 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
</script> </script>
<template> <template>
<div class="card-premium p-8 h-fit"> <div :class="[!flat ? 'card-premium p-6 md:p-8' : '']" class="h-fit">
<h2 class="text-xl font-bold flex items-center gap-3 text-slate-900 dark:text-white mb-6"> <div v-if="!flat" class="flex items-center gap-3 mb-8">
<q-icon name="person" class="text-blue-500 text-2xl" /> <div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
{{ $t('profile.editPersonalDesc') }} <q-icon name="person" class="text-blue-600 dark:text-blue-400 text-xl" />
</h2> </div>
<h2 class="text-xl font-black text-slate-900 dark:text-white">
{{ $t('profile.editPersonalDesc') }}
</h2>
</div>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
/**
* @file dashboard-index.vue
* @description Layout for the Dashboard Index page, without the sidebar.
* Uses Quasar QLayout for responsive structure.
*/
// Initialize global theme management
useThemeMode()
// No sidebar logic needed here as we are removing it
</script>
<template>
<q-layout view="hHh lpR fFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- Header -->
<q-header
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white"
>
<AppHeader :showSidebarToggle="false" navType="learner" />
</q-header>
<!-- Sidebar Removed for this layout -->
<!-- Main Content -->
<q-page-container>
<q-page class="relative">
<slot />
</q-page>
</q-page-container>
<!-- Mobile Bottom Nav - Optional, keeping it consistent with default but maybe not needed if full width?
If we remove sidebar, we might still want mobile nav if it's main navigation.
Let's keep it for now as it doesn't hurt. -->
<q-footer
v-if="$q.screen.lt.md"
class="!bg-white dark:!bg-[#1e293b] text-primary"
>
<MobileNav />
</q-footer>
</q-layout>
</template>
<style>
/* Ensure fonts are applied */
.font-inter {
font-family: var(--font-main);
}
</style>

View file

@ -12,6 +12,13 @@ const leftDrawerOpen = ref(false)
const toggleLeftDrawer = () => { const toggleLeftDrawer = () => {
leftDrawerOpen.value = !leftDrawerOpen.value leftDrawerOpen.value = !leftDrawerOpen.value
} }
const route = useRoute()
// Automatically hide sidebar for learner routes
const shouldHideSidebar = computed(() => {
const silentRoutes = ['/dashboard', '/browse', '/classroom', '/course']
return silentRoutes.some(r => route.path.startsWith(r))
})
</script> </script>
<template> <template>
@ -20,11 +27,12 @@ const toggleLeftDrawer = () => {
<q-header <q-header
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white" class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white"
> >
<AppHeader @toggleSidebar="toggleLeftDrawer" /> <AppHeader @toggleSidebar="toggleLeftDrawer" :showSidebarToggle="!shouldHideSidebar" />
</q-header> </q-header>
<!-- Sidebar (Drawer) --> <!-- Sidebar (Drawer) -->
<q-drawer <q-drawer
v-if="!shouldHideSidebar"
v-model="leftDrawerOpen" v-model="leftDrawerOpen"
show-if-above show-if-above
:width="280" :width="280"

View file

@ -150,39 +150,26 @@ onMounted(() => {
<!-- Top Header Area --> <!-- Top Header Area -->
<div class="flex flex-col gap-6 mb-10"> <div class="flex flex-col gap-6 mb-10">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div class="flex items-start gap-4 mb-4">
<!-- Title --> <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>
<h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3"> <div>
<span class="w-1.5 h-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span> <h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
{{ $t('discovery.title') }} {{ $t('discovery.title') }}
</h1> </h1>
<p v-if="filteredCourses.length > 0" class="text-slate-500 dark:text-slate-400 mt-1 font-medium">
<!-- Right Side: Search --> พบทงหมด <span class="text-blue-600 font-bold leading-none">{{ filteredCourses.length }}</span> รายการ
<div class="flex items-center gap-3 w-full md:w-auto"> </p>
<q-input
v-model="searchQuery"
dense
outlined
rounded
:placeholder="$t('discovery.searchPlaceholder')"
class="w-full md:w-72 search-input shadow-sm"
bg-color="transparent"
>
<template v-slot:prepend>
<q-icon name="search" class="text-slate-400" />
</template>
</q-input>
</div> </div>
</div> </div>
<!-- Unified Filter Section: Categories --> <!-- Unified Filter Section: Categories -->
<div class="flex flex-wrap items-center gap-2"> <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 <q-btn
flat flat
rounded rounded
dense dense
class="px-4 font-bold transition-all text-xs uppercase tracking-widest" class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
:class="selectedCategoryIds.length === 0 ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'" :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 = []" @click="selectedCategoryIds = []"
:label="$t('discovery.showAll')" :label="$t('discovery.showAll')"
/> />
@ -192,8 +179,8 @@ onMounted(() => {
flat flat
rounded rounded
dense dense
class="px-4 font-bold transition-all text-xs uppercase tracking-widest" 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-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'" :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)" @click="toggleCategory(cat.id)"
:label="getLocalizedText(cat.name)" :label="getLocalizedText(cat.name)"
/> />

View file

@ -32,7 +32,7 @@ onMounted(async () => {
try { try {
const [catRes, enrollRes, courseRes] = await Promise.all([ const [catRes, enrollRes, courseRes] = await Promise.all([
fetchCategories(), fetchCategories(),
fetchEnrolledCourses({ limit: 3, status: 'IN_PROGRESS' }), // Fetch recent enrolled 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
]) ])
@ -46,19 +46,33 @@ onMounted(async () => {
// Map Enrolled Courses // Map Enrolled Courses
if (enrollRes.success && enrollRes.data) { if (enrollRes.success && enrollRes.data) {
enrolledCourses.value = enrollRes.data.map((item: any) => ({ // 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
})
enrolledCourses.value = sortedEnrollments.map((item: any) => ({
id: item.course_id, id: item.course_id,
title: item.course.title, title: item.course.title,
thumbnail_url: item.course.thumbnail_url, thumbnail_url: item.course.thumbnail_url,
progress: item.progress_percentage || 0, progress: item.progress_percentage || 0,
total_lessons: item.course.total_lessons || 10, 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
})) }))
// Update libraryCourses with only 2 courses
libraryCourses.value = enrolledCourses.value.slice(0, 2)
} }
// Map Recommended/Library Courses // Map Recommended Courses
if (courseRes.success && courseRes.data) { if (courseRes.success && courseRes.data) {
// Use fetched courses for recommended section
recommendedCourses.value = courseRes.data.map((c: any) => ({ recommendedCourses.value = courseRes.data.map((c: any) => ({
id: c.id, id: c.id,
title: c.title, title: c.title,
@ -70,9 +84,6 @@ onMounted(async () => {
price: c.price, price: c.price,
is_free: c.is_free 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) { } catch (err) {
@ -93,7 +104,46 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
<div class="max-w-7xl mx-auto px-4 md:px-12 space-y-16 mt-10"> <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">
<!-- 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="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>
<p class="text-slate-500 font-medium text-base md:text-lg max-w-xl mx-auto leading-relaxed">
นนณเรยนไปกนาทแล? มาสรางนยการเรยนรยอดเยยมกนเถอะ เรามคอรสแนะนำใหม มากมายรอคณอย
</p>
<div class="flex flex-wrap justify-center gap-4 pt-4">
<q-btn
unelevated
rounded
color="primary"
label="ไปที่คอร์สเรียนของฉัน"
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"
/>
<q-btn
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;"
to="/browse/discovery"
/>
</div>
</div>
</section>
<!-- 2. Continue Learning Section --> <!-- 2. Continue Learning Section -->
<section v-if="enrolledCourses.length > 0"> <section v-if="enrolledCourses.length > 0">
@ -106,11 +156,12 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Hero Card (Left) --> <!-- 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]"> <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" /> <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"> <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 class="bg-blue-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> <h3 class="text-white text-2xl font-bold mb-4 line-clamp-2 leading-snug">{{ getLocalizedText(heroCourse.title) }}</h3>
<!-- Progress --> <!-- Progress -->
@ -119,10 +170,15 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
<span>{{ heroCourse.progress }}%</span> <span>{{ heroCourse.progress }}%</span>
</div> </div>
<div class="h-1.5 w-full bg-white/20 rounded-full overflow-hidden"> <div class="h-1.5 w-full bg-white/20 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 rounded-full" :style="{ width: `${heroCourse.progress}%` }"></div> <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>
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-end">
<span class="text-white font-bold text-sm hover:underline">{{ heroCourse.progress === 100 ? 'เรียนอีกครั้ง' : 'เรียนต่อ' }}</span> <span class="font-bold text-sm hover:underline transition-colors"
:class="heroCourse.progress === 100 ? 'text-emerald-400' : 'text-white'">
{{ heroCourse.progress === 100 ? 'เรียนอีกครั้ง' : 'เรียนต่อ' }}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -139,10 +195,16 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
<div class="mt-auto"> <div class="mt-auto">
<div class="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden mb-2"> <div class="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden mb-2">
<div class="h-full bg-blue-600 rounded-full" :style="{ width: `${course.progress}%` }"></div> <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>
<div class="flex justify-end items-center text-xs"> <div class="flex justify-end items-center text-xs">
<span class="text-blue-600 font-bold cursor-pointer hover:underline" @click="navigateTo(`/classroom/learning?course_id=${course.id}`)">{{ course.progress === 100 ? 'เรียนอีกครั้ง' : 'เรียนต่อ' }}</span> <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>
</div> </div>
</div> </div>
</div> </div>
@ -170,6 +232,8 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
:key="course.id" :key="course.id"
v-bind="course" v-bind="course"
:image="course.thumbnail_url" :image="course.thumbnail_url"
hide-progress
hide-actions
class="h-full md:col-span-1" class="h-full md:col-span-1"
/> />

View file

@ -151,22 +151,41 @@ const validCourseId = computed(() => {
<!-- Page Header & Filters (Unified Layout) --> <!-- Page Header & Filters (Unified Layout) -->
<div class="flex flex-col gap-6 mb-10"> <div class="mb-8">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div class="flex items-start gap-4 mb-2">
<h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3"> <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-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span> <div>
{{ $t('sidebar.myCourses') }} <h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
</h1> {{ $t('sidebar.myCourses') }}
</h1>
</div>
</div>
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mt-4">
<!-- Filter Tabs (Horizontal Bar) -->
<div class="bg-white dark:bg-slate-900/50 p-1.5 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex items-center gap-1 shadow-sm">
<q-btn
v-for="filter in ['all', 'progress', 'completed']"
:key="filter"
@click="activeFilter = filter as any"
flat
rounded
dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
:class="activeFilter === filter ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'"
:label="$t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`)"
/>
</div>
<!-- Search Input --> <!-- Search Input -->
<div class="flex items-center gap-3 w-full md:w-auto"> <div class="w-full md:w-72">
<q-input <q-input
v-model="searchQuery" v-model="searchQuery"
dense dense
outlined outlined
rounded rounded
:placeholder="$t('discovery.searchPlaceholder')" :placeholder="$t('discovery.searchPlaceholder')"
class="w-full md:w-72 search-input shadow-sm" class="search-input shadow-sm"
bg-color="transparent" bg-color="transparent"
> >
<template v-slot:prepend> <template v-slot:prepend>
@ -175,21 +194,6 @@ const validCourseId = computed(() => {
</q-input> </q-input>
</div> </div>
</div> </div>
<!-- Filter Tabs (Horizontal Bar) -->
<div class="flex flex-wrap items-center gap-2">
<q-btn
v-for="filter in ['all', 'progress', 'completed']"
:key="filter"
@click="activeFilter = filter as any"
flat
rounded
dense
class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
:class="activeFilter === filter ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'"
:label="$t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`)"
/>
</div>
</div> </div>
<!-- Courses Grid --> <!-- Courses Grid -->

View file

@ -15,6 +15,7 @@ const { locale, t } = useI18n()
const isEditing = ref(false) const isEditing = ref(false)
const activeTab = ref<'general' | 'security'>('general')
const isProfileSaving = ref(false) const isProfileSaving = ref(false)
const isPasswordSaving = ref(false) const isPasswordSaving = ref(false)
const isSendingVerify = ref(false) const isSendingVerify = ref(false)
@ -204,20 +205,24 @@ onMounted(async () => {
<template> <template>
<div class="page-container"> <div class="page-container">
<div class="flex items-center justify-between mb-8 md:mb-10"> <div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<q-btn <q-btn
v-if="isHydrated && isEditing" v-if="isHydrated && isEditing"
flat flat
round round
icon="arrow_back" icon="arrow_back"
color="slate-700"
class="dark:text-white" class="dark:text-white"
@click="toggleEdit(false)" @click="toggleEdit(false)"
/> />
<h1 class="text-3xl font-black text-slate-900 dark:text-white"> <div class="flex items-start gap-4">
{{ (isHydrated && isEditing) ? $t('profile.editProfile') : $t('profile.myProfile') }} <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>
</h1> <div>
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
{{ (isHydrated && isEditing) ? $t('profile.editProfile') : $t('profile.myProfile') }}
</h1>
</div>
</div>
</div> </div>
<div class="min-h-9 flex items-center"> <div class="min-h-9 flex items-center">
@ -242,60 +247,127 @@ onMounted(async () => {
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<div v-else> <div v-else class="max-w-4xl mx-auto">
<div v-if="!isEditing" class="card-premium overflow-hidden fade-in"> <!-- Unified Premium Container -->
<div class="bg-gradient-to-r from-blue-600 to-indigo-600 h-32 w-full"/> <div class="card-premium overflow-hidden fade-in min-h-[600px] flex flex-col">
<div class="px-8 pb-10 -mt-16">
<div class="flex flex-col md:flex-row items-end gap-6 mb-10"> <!-- Part 1: Identity Header (Banner & Avatar) -->
<div class="relative flex-shrink-0"> <div class="relative">
<div class="h-40 bg-gradient-to-r from-blue-700 via-blue-600 to-indigo-700 relative overflow-hidden">
<!-- Abstract Patterns -->
<div class="absolute inset-0 opacity-10">
<div class="absolute -top-10 -right-10 w-64 h-64 rounded-full bg-white blur-3xl"></div>
<div class="absolute -bottom-10 -left-10 w-48 h-48 rounded-full bg-indigo-300 blur-3xl"></div>
</div>
</div>
<div 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="relative group flex-shrink-0">
<UserAvatar <UserAvatar
:photo-u-r-l="userData.photoURL" :photo-u-r-l="userData.photoURL"
:first-name="userData.firstName" :first-name="userData.firstName"
:last-name="userData.lastName" :last-name="userData.lastName"
size="128" size="140"
class="border-4 border-white dark:border-[#1e293b] shadow-2xl bg-slate-800" class="border-[6px] border-white dark:border-slate-800 shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-900"
/> />
</div>
</div> <div class="text-center md:text-left pt-4 md:pt-0 flex-grow min-w-0">
<div class="pb-2"> <h2 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white mb-2 leading-tight tracking-tight break-words">
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-1">{{ userData.firstName }} {{ userData.lastName }}</h2> {{ userData.firstName }} {{ userData.lastName }}
<p class="text-slate-500 dark:text-slate-400 font-medium">{{ userData.email }}</p> </h2>
</div> <div class="flex flex-wrap items-center justify-center md:justify-start gap-4">
</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">
<q-icon name="alternate_email" size="xs" class="text-blue-500" />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <span class="text-sm">{{ userData.email }}</span>
<div class="info-group"> </div>
<span class="label">{{ $t('profile.phone') }}</span> <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">
<p class="value">{{ userData.phone || '-' }}</p> <q-icon name="verified_user" size="xs" :class="userData.emailVerifiedAt ? 'text-green-500' : 'text-amber-500'" />
</div> <span class="text-sm">{{ userData.emailVerifiedAt ? 'ยืนยันแล้ว' : 'รอการยืนยัน' }}</span>
<div class="info-group"> </div>
<span class="label">{{ $t('profile.joinedAt') }}</span> </div>
<p class="value">{{ formatDate(userData.createdAt) }}</p>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Part 2: Interactive Controls & 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>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8 fade-in"> <!-- Content Switching -->
<div class="max-w-3xl mx-auto h-full">
<ProfileEditForm <template v-if="!isEditing">
v-model="userData" <div class="fade-in">
:loading="isProfileSaving" <h3 class="text-sm font-black text-slate-400 uppercase tracking-widest flex items-center gap-2 mb-8">
:verifying="isSendingVerify" <span class="w-2 h-2 bg-blue-600 rounded-full"></span> รายละเอยดบญช
@submit="handleUpdateProfile" </h3>
@upload="handleFileUpload" <div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
@verify="handleSendVerifyEmail" <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">
<q-icon name="smartphone" size="24px" />
</div>
<PasswordChangeForm <div>
v-model="passwordForm" <div class="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-0.5">เบอรโทรศพท</div>
:loading="isPasswordSaving" <div class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">{{ userData.phone || '-' }}</div>
@submit="handleUpdatePassword" </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">
<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-lg font-bold text-slate-900 dark:text-white tracking-tight">{{ formatDate(userData.createdAt) }}</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="fade-in">
<div v-if="activeTab === 'general'">
<ProfileEditForm
v-model="userData"
:loading="isProfileSaving"
:verifying="isSendingVerify"
@submit="handleUpdateProfile"
@upload="handleFileUpload"
@verify="handleSendVerifyEmail"
flat
/>
</div>
<div v-else>
<PasswordChangeForm
v-model="passwordForm"
:loading="isPasswordSaving"
@submit="handleUpdatePassword"
flat
/>
</div>
</div>
</template>
</div>
</div>
</div> </div>
</div> </div>