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
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:
parent
c118e5c3dc
commit
0f92f0d00c
10 changed files with 446 additions and 195 deletions
|
|
@ -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,6 +119,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div v-if="!hideActions" class="flex flex-col gap-3">
|
||||
<!-- View Details (Secondary Action) -->
|
||||
<q-btn
|
||||
v-if="showViewDetails && !completed && !progress"
|
||||
|
|
@ -136,6 +139,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
: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 -->
|
||||
|
|
|
|||
|
|
@ -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,13 +21,26 @@ 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">
|
||||
<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
|
||||
|
|
@ -41,10 +60,42 @@ const searchText = ref('')
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center min-w-[200px] md:min-w-[320px] max-w-sm ml-8 mr-8 h-10">
|
||||
<q-input
|
||||
v-model="searchText"
|
||||
dense
|
||||
borderless
|
||||
placeholder="ค้นหาคอร์สเรียน..."
|
||||
class="search-input w-full bg-slate-100/60 px-4 rounded-full transition-all duration-300 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-100 h-full flex items-center"
|
||||
@keyup.enter="navigateTo(`/browse/discovery?search=${searchText}`)"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="search" size="xs" class="text-blue-600" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="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">
|
||||
|
||||
<!-- 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-icon name="keyboard_arrow_down" />
|
||||
<q-menu>
|
||||
<q-list dense style="min-width: 150px">
|
||||
<q-item clickable v-close-popup to="/browse">
|
||||
|
|
@ -62,6 +113,7 @@ const searchText = ref('')
|
|||
<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 />
|
||||
|
|
@ -77,6 +129,7 @@ const searchText = ref('')
|
|||
<!-- User Profile -->
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</q-toolbar>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
<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">
|
||||
|
||||
|
|
|
|||
49
Frontend-Learner/layouts/dashboard-index.vue
Normal file
49
Frontend-Learner/layouts/dashboard-index.vue
Normal 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>
|
||||
|
|
@ -12,6 +12,13 @@ const leftDrawerOpen = ref(false)
|
|||
const toggleLeftDrawer = () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -20,11 +27,12 @@ const toggleLeftDrawer = () => {
|
|||
<q-header
|
||||
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>
|
||||
|
||||
<!-- Sidebar (Drawer) -->
|
||||
<q-drawer
|
||||
v-if="!shouldHideSidebar"
|
||||
v-model="leftDrawerOpen"
|
||||
show-if-above
|
||||
:width="280"
|
||||
|
|
|
|||
|
|
@ -150,39 +150,26 @@ onMounted(() => {
|
|||
|
||||
<!-- Top Header Area -->
|
||||
<div class="flex flex-col gap-6 mb-10">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<!-- Title -->
|
||||
<h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3">
|
||||
<span class="w-1.5 h-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span>
|
||||
<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>
|
||||
<div>
|
||||
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
|
||||
{{ $t('discovery.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Right Side: Search -->
|
||||
<div class="flex items-center gap-3 w-full md:w-auto">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
flat
|
||||
rounded
|
||||
dense
|
||||
class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
|
||||
: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="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'"
|
||||
@click="selectedCategoryIds = []"
|
||||
:label="$t('discovery.showAll')"
|
||||
/>
|
||||
|
|
@ -192,8 +179,8 @@ onMounted(() => {
|
|||
flat
|
||||
rounded
|
||||
dense
|
||||
class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
|
||||
: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="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
|
||||
:class="selectedCategoryIds.includes(cat.id) ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'"
|
||||
@click="toggleCategory(cat.id)"
|
||||
:label="getLocalizedText(cat.name)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ onMounted(async () => {
|
|||
try {
|
||||
const [catRes, enrollRes, courseRes] = await Promise.all([
|
||||
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
|
||||
])
|
||||
|
||||
|
|
@ -46,19 +46,33 @@ onMounted(async () => {
|
|||
|
||||
// Map Enrolled Courses
|
||||
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,
|
||||
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))
|
||||
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) {
|
||||
// Use fetched courses for recommended section
|
||||
recommendedCourses.value = courseRes.data.map((c: any) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
|
|
@ -70,9 +84,6 @@ onMounted(async () => {
|
|||
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) {
|
||||
|
|
@ -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 -->
|
||||
<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">
|
||||
<!-- 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" />
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Progress -->
|
||||
|
|
@ -119,10 +170,15 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
|
|||
<span>{{ heroCourse.progress }}%</span>
|
||||
</div>
|
||||
<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 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>
|
||||
|
|
@ -139,10 +195,16 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
|
|||
|
||||
<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-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 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>
|
||||
|
|
@ -170,6 +232,8 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
|
|||
:key="course.id"
|
||||
v-bind="course"
|
||||
:image="course.thumbnail_url"
|
||||
hide-progress
|
||||
hide-actions
|
||||
class="h-full md:col-span-1"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -151,33 +151,19 @@ const validCourseId = computed(() => {
|
|||
|
||||
|
||||
<!-- Page Header & Filters (Unified Layout) -->
|
||||
<div class="flex flex-col gap-6 mb-10">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3">
|
||||
<span class="w-1.5 h-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span>
|
||||
<div class="mb-8">
|
||||
<div class="flex items-start gap-4 mb-2">
|
||||
<span class="w-1.5 h-10 md:h-12 bg-blue-600 rounded-full shadow-lg shadow-blue-500/50 mt-1 flex-shrink-0"></span>
|
||||
<div>
|
||||
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
|
||||
{{ $t('sidebar.myCourses') }}
|
||||
</h1>
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="flex items-center gap-3 w-full md:w-auto">
|
||||
<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 class="flex flex-col md:flex-row md:items-center justify-between gap-4 mt-4">
|
||||
<!-- Filter Tabs (Horizontal Bar) -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<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"
|
||||
|
|
@ -185,11 +171,29 @@ const validCourseId = computed(() => {
|
|||
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'"
|
||||
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 -->
|
||||
<div class="w-full md:w-72">
|
||||
<q-input
|
||||
v-model="searchQuery"
|
||||
dense
|
||||
outlined
|
||||
rounded
|
||||
:placeholder="$t('discovery.searchPlaceholder')"
|
||||
class="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>
|
||||
|
||||
<!-- Courses Grid -->
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const { locale, t } = useI18n()
|
|||
|
||||
|
||||
const isEditing = ref(false)
|
||||
const activeTab = ref<'general' | 'security'>('general')
|
||||
const isProfileSaving = ref(false)
|
||||
const isPasswordSaving = ref(false)
|
||||
const isSendingVerify = ref(false)
|
||||
|
|
@ -204,21 +205,25 @@ onMounted(async () => {
|
|||
<template>
|
||||
<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">
|
||||
<q-btn
|
||||
v-if="isHydrated && isEditing"
|
||||
flat
|
||||
round
|
||||
icon="arrow_back"
|
||||
color="slate-700"
|
||||
class="dark:text-white"
|
||||
@click="toggleEdit(false)"
|
||||
/>
|
||||
<h1 class="text-3xl font-black text-slate-900 dark:text-white">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="w-1.5 h-10 md:h-12 bg-blue-600 rounded-full shadow-lg shadow-blue-500/50 mt-1 flex-shrink-0"></span>
|
||||
<div>
|
||||
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
|
||||
{{ (isHydrated && isEditing) ? $t('profile.editProfile') : $t('profile.myProfile') }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-9 flex items-center">
|
||||
<q-btn
|
||||
|
|
@ -242,44 +247,105 @@ onMounted(async () => {
|
|||
<q-spinner size="3rem" color="primary" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="!isEditing" class="card-premium overflow-hidden fade-in">
|
||||
<div class="bg-gradient-to-r from-blue-600 to-indigo-600 h-32 w-full"/>
|
||||
<div class="px-8 pb-10 -mt-16">
|
||||
<div class="flex flex-col md:flex-row items-end gap-6 mb-10">
|
||||
<div class="relative flex-shrink-0">
|
||||
<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">
|
||||
|
||||
<!-- Part 1: 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 -->
|
||||
<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
|
||||
:photo-u-r-l="userData.photoURL"
|
||||
:first-name="userData.firstName"
|
||||
:last-name="userData.lastName"
|
||||
size="128"
|
||||
class="border-4 border-white dark:border-[#1e293b] shadow-2xl bg-slate-800"
|
||||
size="140"
|
||||
class="border-[6px] border-white dark:border-slate-800 shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-900"
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-1">{{ userData.firstName }} {{ userData.lastName }}</h2>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium">{{ userData.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div class="info-group">
|
||||
<span class="label">{{ $t('profile.phone') }}</span>
|
||||
<p class="value">{{ userData.phone || '-' }}</p>
|
||||
<div class="text-center md:text-left pt-4 md:pt-0 flex-grow min-w-0">
|
||||
<h2 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white mb-2 leading-tight tracking-tight break-words">
|
||||
{{ userData.firstName }} {{ userData.lastName }}
|
||||
</h2>
|
||||
<div class="flex flex-wrap items-center justify-center md:justify-start gap-4">
|
||||
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-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" />
|
||||
<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">
|
||||
<q-icon name="verified_user" size="xs" :class="userData.emailVerifiedAt ? 'text-green-500' : 'text-amber-500'" />
|
||||
<span class="text-sm">{{ userData.emailVerifiedAt ? 'ยืนยันแล้ว' : 'รอการยืนยัน' }}</span>
|
||||
</div>
|
||||
<div class="info-group">
|
||||
<span class="label">{{ $t('profile.joinedAt') }}</span>
|
||||
<p class="value">{{ formatDate(userData.createdAt) }}</p>
|
||||
</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">
|
||||
<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> รายละเอียดบัญชี
|
||||
</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">
|
||||
<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-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">
|
||||
<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"
|
||||
|
|
@ -287,15 +353,21 @@ onMounted(async () => {
|
|||
@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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue