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

View file

@ -5,9 +5,15 @@
* Uses Quasar QToolbar.
*/
defineProps<{
import { ref, computed } from 'vue'
const props = defineProps<{
/** Controls visibility of the search bar */
showSearch?: boolean
/** Controls visibility of the sidebar toggle button */
showSidebarToggle?: boolean
/** Type of navigation links to display */
navType?: 'public' | 'learner'
}>()
const emit = defineEmits<{
@ -15,67 +21,114 @@ const emit = defineEmits<{
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('')
</script>
<template>
<q-toolbar class="bg-white text-slate-900 h-16 px-4 md:px-8">
<!-- Mobile Menu Toggle -->
<q-btn
flat
round
dense
icon="menu"
class="lg:hidden mr-2 text-gray-500"
@click="$emit('toggleSidebar')"
/>
<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">
<!-- Mobile Menu Toggle -->
<q-btn
v-if="showSidebarToggle !== false && navTypeComputed !== 'learner'"
flat
round
dense
icon="menu"
class="lg:hidden mr-2 text-gray-500"
@click="$emit('toggleSidebar')"
/>
<!-- Branding -->
<div class="flex items-center gap-3 cursor-pointer group" @click="navigateTo('/dashboard')">
<div class="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white font-black shadow-lg shadow-blue-600/30 group-hover:scale-110 transition-transform">
E
<!-- Branding -->
<div class="flex items-center gap-3 cursor-pointer group" @click="navigateTo('/dashboard')">
<div class="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white font-black shadow-lg shadow-blue-600/30 group-hover:scale-110 transition-transform">
E
</div>
<div class="flex flex-col">
<span class="font-black text-lg leading-none tracking-tight text-slate-900 group-hover:text-blue-600 transition-colors">E-Learning</span>
<span class="text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500">Platform</span>
</div>
</div>
<div class="flex 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 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="ค้นหาคอร์สเรียน..."
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>
<!-- Desktop Navigation -->
<nav class="hidden lg:flex items-center gap-6 text-sm font-medium text-gray-600">
<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>
<!-- 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>
<NuxtLink to="/browse/discovery" class="hover:text-blue-600 transition-colors uppercase tracking-wider" active-class="text-blue-600">
คอรสเรยนทงหมด
</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 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>
</q-toolbar>
</template>

View file

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