feat: Add initial pages and components for user dashboard, profile, course discovery, and classroom learning with i18n support.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 47s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 47s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
This commit is contained in:
parent
f26a94076c
commit
e3873f616e
11 changed files with 1046 additions and 685 deletions
|
|
@ -33,29 +33,31 @@ const currentPage = ref(1);
|
|||
const totalPages = ref(1);
|
||||
const itemsPerPage = 12;
|
||||
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { currentUser } = useAuth();
|
||||
const $q = useQuasar();
|
||||
const { fetchCategories } = useCategory();
|
||||
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse();
|
||||
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } =
|
||||
useCourse();
|
||||
|
||||
// 2. Computed Properties
|
||||
const sortOption = ref(t('discovery.sortRecent'));
|
||||
const sortOptions = computed(() => [t('discovery.sortRecent')]);
|
||||
const sortOption = ref(t("discovery.sortRecent"));
|
||||
const sortOptions = computed(() => [t("discovery.sortRecent")]);
|
||||
|
||||
const filteredCourses = computed(() => {
|
||||
let result = courses.value;
|
||||
|
||||
// If more than 1 category is selected, we still do client-side filtering
|
||||
|
||||
// If more than 1 category is selected, we still do client-side filtering
|
||||
// because the API currently only supports one category_id at a time.
|
||||
if (selectedCategoryIds.value.length > 1) {
|
||||
result = result.filter(c => selectedCategoryIds.value.includes(c.category_id));
|
||||
result = result.filter((c) =>
|
||||
selectedCategoryIds.value.includes(c.category_id),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
result = result.filter(c => {
|
||||
result = result.filter((c) => {
|
||||
const title = getLocalizedText(c.title).toLowerCase();
|
||||
const desc = getLocalizedText(c.description).toLowerCase();
|
||||
return title.includes(query) || (desc && desc.includes(query));
|
||||
|
|
@ -66,7 +68,6 @@ const filteredCourses = computed(() => {
|
|||
|
||||
// 3. Helper Functions
|
||||
|
||||
|
||||
// 4. API Actions
|
||||
const loadCategories = async () => {
|
||||
const res = await fetchCategories();
|
||||
|
|
@ -75,17 +76,20 @@ const loadCategories = async () => {
|
|||
|
||||
const loadCourses = async (page = 1) => {
|
||||
isLoading.value = true;
|
||||
|
||||
|
||||
// Use server-side filtering if exactly one category is selected
|
||||
const categoryId = selectedCategoryIds.value.length === 1 ? selectedCategoryIds.value[0] : undefined;
|
||||
|
||||
const categoryId =
|
||||
selectedCategoryIds.value.length === 1
|
||||
? selectedCategoryIds.value[0]
|
||||
: undefined;
|
||||
|
||||
const res = await fetchCourses({
|
||||
category_id: categoryId,
|
||||
page: page,
|
||||
limit: itemsPerPage,
|
||||
forceRefresh: true
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
|
||||
if (res.success) {
|
||||
courses.value = res.data || [];
|
||||
totalPages.value = res.totalPages || 1;
|
||||
|
|
@ -108,33 +112,37 @@ const handleEnroll = async (id: number) => {
|
|||
isEnrolling.value = true;
|
||||
const res = await enrollCourse(id);
|
||||
if (res.success) {
|
||||
return navigateTo('/dashboard/my-courses?enrolled=true');
|
||||
return navigateTo("/dashboard/my-courses?enrolled=true");
|
||||
} else {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: res.error || t('enrollment.error'),
|
||||
position: 'top',
|
||||
timeout: 3000,
|
||||
actions: [{ icon: 'close', color: 'white' }]
|
||||
})
|
||||
type: "negative",
|
||||
message: res.error || t("enrollment.error"),
|
||||
position: "top",
|
||||
timeout: 3000,
|
||||
actions: [{ icon: "close", color: "white" }],
|
||||
});
|
||||
}
|
||||
isEnrolling.value = false;
|
||||
};
|
||||
|
||||
// Watch for category selection changes to reload courses
|
||||
watch(selectedCategoryIds, () => {
|
||||
currentPage.value = 1;
|
||||
loadCourses(1);
|
||||
}, { deep: true });
|
||||
watch(
|
||||
selectedCategoryIds,
|
||||
() => {
|
||||
currentPage.value = 1;
|
||||
loadCourses(1);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const toggleCategory = (id: number) => {
|
||||
const index = selectedCategoryIds.value.indexOf(id)
|
||||
const index = selectedCategoryIds.value.indexOf(id);
|
||||
if (index === -1) {
|
||||
selectedCategoryIds.value.push(id)
|
||||
selectedCategoryIds.value.push(id);
|
||||
} else {
|
||||
selectedCategoryIds.value.splice(index, 1)
|
||||
selectedCategoryIds.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories();
|
||||
|
|
@ -144,109 +152,145 @@ onMounted(() => {
|
|||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
|
||||
<!-- CATALOG VIEW: Browse courses -->
|
||||
<div v-if="!showDetail">
|
||||
|
||||
<!-- Top Header Area -->
|
||||
<div class="flex flex-col gap-6 mb-10">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<span class="w-1.5 h-10 md:h-12 bg-blue-600 rounded-full shadow-lg shadow-blue-500/50 mt-1 flex-shrink-0"></span>
|
||||
<span
|
||||
class="w-1.5 h-10 md:h-12 bg-blue-600 rounded-full shadow-lg shadow-blue-500/50 mt-1 flex-shrink-0"
|
||||
></span>
|
||||
<div>
|
||||
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
|
||||
{{ $t('discovery.title') }}
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight"
|
||||
>
|
||||
{{ $t("discovery.title") }}
|
||||
</h1>
|
||||
<p v-if="filteredCourses.length > 0" class="text-slate-500 dark:text-slate-400 mt-1 font-medium">
|
||||
พบทั้งหมด <span class="text-blue-600 font-bold leading-none">{{ filteredCourses.length }}</span> รายการ
|
||||
<p
|
||||
v-if="filteredCourses.length > 0"
|
||||
class="text-slate-500 dark:text-slate-400 mt-1 font-medium"
|
||||
>
|
||||
{{ $t("discovery.foundTotal") }}
|
||||
<span class="text-blue-600 font-bold leading-none">{{
|
||||
filteredCourses.length
|
||||
}}</span>
|
||||
{{ $t("discovery.items") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unified Filter Section: Categories -->
|
||||
<div class="bg-white dark:bg-slate-900/50 p-2 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex flex-wrap items-center gap-1.5 shadow-sm">
|
||||
<q-btn
|
||||
flat
|
||||
rounded
|
||||
dense
|
||||
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
|
||||
:class="selectedCategoryIds.length === 0 ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'"
|
||||
@click="selectedCategoryIds = []"
|
||||
:label="$t('discovery.showAll')"
|
||||
/>
|
||||
<q-btn
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
flat
|
||||
rounded
|
||||
dense
|
||||
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
|
||||
:class="selectedCategoryIds.includes(cat.id) ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'"
|
||||
@click="toggleCategory(cat.id)"
|
||||
:label="getLocalizedText(cat.name)"
|
||||
/>
|
||||
<div
|
||||
class="bg-white dark:bg-slate-900/50 p-2 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex flex-wrap items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<q-btn
|
||||
flat
|
||||
rounded
|
||||
dense
|
||||
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
|
||||
:class="
|
||||
selectedCategoryIds.length === 0
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
"
|
||||
@click="selectedCategoryIds = []"
|
||||
:label="$t('discovery.showAll')"
|
||||
/>
|
||||
<q-btn
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
flat
|
||||
rounded
|
||||
dense
|
||||
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
|
||||
:class="
|
||||
selectedCategoryIds.includes(cat.id)
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
"
|
||||
@click="toggleCategory(cat.id)"
|
||||
:label="getLocalizedText(cat.name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Layout: Grid Only -->
|
||||
<div class="w-full">
|
||||
<div v-if="filteredCourses.length > 0" class="flex flex-col gap-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
<CourseCard
|
||||
v-for="course in filteredCourses"
|
||||
:key="course.id"
|
||||
v-bind="{ ...course, image: course.thumbnail_url }"
|
||||
show-view-details
|
||||
@view-details="selectCourse(course.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div v-if="totalPages > 1" class="flex justify-center pb-10">
|
||||
<q-pagination
|
||||
v-model="currentPage"
|
||||
:max="totalPages"
|
||||
:max-pages="6"
|
||||
boundary-numbers
|
||||
direction-links
|
||||
color="primary"
|
||||
flat
|
||||
active-design="unelevated"
|
||||
active-color="primary"
|
||||
@update:model-value="loadCourses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredCourses.length > 0" class="flex flex-col gap-12">
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 shadow-sm"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
|
||||
>
|
||||
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
|
||||
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
|
||||
{{ $t('discovery.emptyDesc') }}
|
||||
</p>
|
||||
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 dark:hover:text-blue-400 transition-colors" @click="searchQuery = ''; selectedCategoryIds = []">
|
||||
{{ $t('discovery.showAll') }}
|
||||
</button>
|
||||
<CourseCard
|
||||
v-for="course in filteredCourses"
|
||||
:key="course.id"
|
||||
v-bind="{ ...course, image: course.thumbnail_url }"
|
||||
show-view-details
|
||||
@view-details="selectCourse(course.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div v-if="totalPages > 1" class="flex justify-center pb-10">
|
||||
<q-pagination
|
||||
v-model="currentPage"
|
||||
:max="totalPages"
|
||||
:max-pages="6"
|
||||
boundary-numbers
|
||||
direction-links
|
||||
color="primary"
|
||||
flat
|
||||
active-design="unelevated"
|
||||
active-color="primary"
|
||||
@update:model-value="loadCourses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 shadow-sm"
|
||||
>
|
||||
<q-icon
|
||||
name="search_off"
|
||||
size="64px"
|
||||
class="text-slate-300 dark:text-slate-600 mb-4"
|
||||
/>
|
||||
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
{{ $t("discovery.emptyTitle") }}
|
||||
</h3>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
|
||||
{{ $t("discovery.emptyDesc") }}
|
||||
</p>
|
||||
<button
|
||||
class="mt-6 font-bold text-blue-600 hover:text-blue-700 dark:hover:text-blue-400 transition-colors"
|
||||
@click="
|
||||
searchQuery = '';
|
||||
selectedCategoryIds = [];
|
||||
"
|
||||
>
|
||||
{{ $t("discovery.showAll") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
|
||||
<div v-else>
|
||||
<button
|
||||
@click="showDetail = false"
|
||||
<button
|
||||
@click="showDetail = false"
|
||||
class="inline-flex items-center gap-2 text-slate-600 dark:text-white hover:text-blue-600 dark:hover:text-blue-300 mb-6 transition-all font-black text-lg md:text-xl group"
|
||||
>
|
||||
<q-icon name="arrow_back" size="24px" class="transition-transform group-hover:-translate-x-1" />
|
||||
{{ $t('discovery.backToCatalog') }}
|
||||
<q-icon
|
||||
name="arrow_back"
|
||||
size="24px"
|
||||
class="transition-transform group-hover:-translate-x-1"
|
||||
/>
|
||||
{{ $t("discovery.backToCatalog") }}
|
||||
</button>
|
||||
|
||||
<div v-if="isLoadingDetail" class="flex justify-center py-20">
|
||||
<q-spinner size="3rem" color="primary" />
|
||||
<q-spinner size="3rem" color="primary" />
|
||||
</div>
|
||||
|
||||
<CourseDetailView
|
||||
|
|
@ -285,4 +329,3 @@ onMounted(() => {
|
|||
box-shadow: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ const isPlaying = ref(false)
|
|||
const videoProgress = ref(0)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const activeTab = ref('content')
|
||||
|
||||
|
||||
|
||||
|
|
@ -604,60 +605,67 @@ onBeforeUnmount(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout view="hHh LpR lFf" class="bg-[var(--bg-body)] text-[var(--text-main)]">
|
||||
<q-layout view="hHh lpR lFf" class="bg-[var(--bg-body)] text-[var(--text-main)]">
|
||||
|
||||
<!-- Header -->
|
||||
<q-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-14">
|
||||
<q-toolbar>
|
||||
<!-- Exit/Back Button -->
|
||||
<q-btn
|
||||
flat
|
||||
rounded
|
||||
no-caps
|
||||
color="primary"
|
||||
class="mr-4 bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-bold hover:bg-slate-200"
|
||||
@click="handleExit('/dashboard/my-courses')"
|
||||
>
|
||||
<q-icon name="close" size="18px" class="mr-1.5" />
|
||||
<span class="hidden sm:inline">{{ $t('common.close') }}</span>
|
||||
<q-tooltip>{{ $t('classroom.backToDashboard') }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-16">
|
||||
<q-toolbar class="h-full px-4">
|
||||
<!-- 1. Left Side: Back & Title -->
|
||||
<div class="flex items-center gap-4 flex-grow overflow-hidden">
|
||||
<!-- Back Button -->
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
color="primary"
|
||||
class="bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
||||
@click="handleExit('/dashboard/my-courses')"
|
||||
>
|
||||
<q-icon name="arrow_back" size="20px" />
|
||||
<q-tooltip>{{ $t('classroom.backToDashboard') }}</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<!-- Course Title -->
|
||||
<div class="flex flex-col">
|
||||
|
||||
<!-- Sidebar Toggle (Clearer for Course Content) -->
|
||||
<q-btn
|
||||
flat
|
||||
rounded
|
||||
no-caps
|
||||
class="mr-2 text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors font-bold px-3"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<q-icon name="format_list_bulleted" size="18px" class="mr-1.5" />
|
||||
<span class="hidden md:inline">{{ $t('classroom.curriculum') }}</span>
|
||||
</q-btn>
|
||||
|
||||
<q-toolbar-title class="text-base font-bold text-left truncate text-slate-900 dark:text-white">
|
||||
{{ courseData ? getLocalizedText(courseData.course.title) : $t('classroom.loadingTitle') }}
|
||||
</q-toolbar-title>
|
||||
<h1 class="text-base md:text-lg font-bold text-slate-900 dark:text-white truncate max-w-[200px] md:max-w-md leading-tight">
|
||||
{{ courseData ? getLocalizedText(courseData.course.title) : $t('classroom.loadingTitle') }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pr-2">
|
||||
<!-- Announcements Button -->
|
||||
<!-- 2. Right Side: Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Sidebar Toggle (Right Side) -->
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
class="text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<q-icon name="menu_open" size="24px" class="transform rotate-180" />
|
||||
<q-tooltip>{{ $t('classroom.curriculum') }}</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<!-- Announcements Button (Refined) -->
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="campaign"
|
||||
class="bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-slate-700 transition-all relative overflow-visible"
|
||||
@click="handleOpenAnnouncements"
|
||||
class="text-slate-600 dark:text-slate-300 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<q-badge v-if="hasUnreadAnnouncements" color="red" floating rounded />
|
||||
<q-icon name="campaign" size="22px" />
|
||||
<!-- Red Dot Notification -->
|
||||
<span v-if="hasUnreadAnnouncements" class="absolute top-2 right-2 w-2.5 h-2.5 bg-rose-500 border-2 border-white dark:border-slate-900 rounded-full"></span>
|
||||
<q-tooltip>{{ $t('classroom.announcements') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
<!-- Sidebar (Curriculum) -->
|
||||
<!-- Sidebar (Curriculum) -->
|
||||
<!-- Sidebar (Curriculum) - Positioned Right via component prop -->
|
||||
<CurriculumSidebar
|
||||
v-model="sidebarOpen"
|
||||
:courseData="courseData"
|
||||
|
|
@ -672,7 +680,7 @@ onBeforeUnmount(() => {
|
|||
<q-page-container class="bg-white dark:bg-slate-900">
|
||||
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]">
|
||||
<!-- Video Player & Content Area -->
|
||||
<div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow">
|
||||
<div class="w-full h-full p-4 md:p-6 flex-grow overflow-y-auto">
|
||||
<!-- 1. LOADING STATE (Comprehensive Skeleton) -->
|
||||
<div v-if="isLessonLoading" class="animate-fade-in">
|
||||
<!-- Video Skeleton -->
|
||||
|
|
|
|||
|
|
@ -5,295 +5,400 @@
|
|||
*/
|
||||
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
middleware: 'auth'
|
||||
})
|
||||
layout: "default",
|
||||
middleware: "auth",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: 'Dashboard - FutureSkill Clone'
|
||||
})
|
||||
title: "Dashboard - FutureSkill Clone",
|
||||
});
|
||||
|
||||
const { currentUser } = useAuth()
|
||||
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse()
|
||||
const { fetchCategories } = useCategory()
|
||||
const { t } = useI18n()
|
||||
const { currentUser } = useAuth();
|
||||
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse();
|
||||
const { fetchCategories } = useCategory();
|
||||
const { t } = useI18n();
|
||||
|
||||
// State
|
||||
const enrolledCourses = ref<any[]>([])
|
||||
const recommendedCourses = ref<any[]>([])
|
||||
const libraryCourses = ref<any[]>([])
|
||||
const categories = ref<any[]>([])
|
||||
const enrolledCourses = ref<any[]>([]);
|
||||
const recommendedCourses = ref<any[]>([]);
|
||||
const libraryCourses = ref<any[]>([]);
|
||||
const categories = ref<any[]>([]);
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isLoading = ref(true);
|
||||
|
||||
// Initial Data Fetch
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const [catRes, enrollRes, courseRes] = await Promise.all([
|
||||
fetchCategories(),
|
||||
fetchEnrolledCourses({ limit: 10 }), // Fetch more enrolled courses for library section
|
||||
fetchCourses({ limit: 3, random: true, forceRefresh: true, is_recommended: true }) // Fetch 3 Recommended Courses
|
||||
])
|
||||
fetchCourses({
|
||||
limit: 3,
|
||||
random: true,
|
||||
forceRefresh: true,
|
||||
is_recommended: true,
|
||||
}), // Fetch 3 Recommended Courses
|
||||
]);
|
||||
|
||||
if (catRes.success) {
|
||||
categories.value = catRes.data || []
|
||||
categories.value = catRes.data || [];
|
||||
}
|
||||
|
||||
const catMap = new Map()
|
||||
categories.value.forEach((c: any) => catMap.set(c.id, c.name))
|
||||
|
||||
|
||||
const catMap = new Map();
|
||||
categories.value.forEach((c: any) => catMap.set(c.id, c.name));
|
||||
|
||||
// Map Enrolled Courses
|
||||
if (enrollRes.success && enrollRes.data) {
|
||||
// Sort by last_accessed_at descending (Newest first)
|
||||
const sortedEnrollments = [...enrollRes.data].sort((a, b) => {
|
||||
const dateA = new Date(a.last_accessed_at || a.enrolled_at).getTime()
|
||||
const dateB = new Date(b.last_accessed_at || b.enrolled_at).getTime()
|
||||
return dateB - dateA
|
||||
})
|
||||
// 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)),
|
||||
// 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
|
||||
}))
|
||||
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),
|
||||
),
|
||||
// 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)
|
||||
// Update libraryCourses with only 2 courses
|
||||
libraryCourses.value = enrolledCourses.value.slice(0, 2);
|
||||
}
|
||||
|
||||
// Map Recommended Courses
|
||||
if (courseRes.success && courseRes.data) {
|
||||
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
|
||||
}))
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard data', err)
|
||||
console.error("Failed to load dashboard data", err);
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Helper for "Continue Learning" Hero Card
|
||||
const heroCourse = computed(() => enrolledCourses.value[0] || null)
|
||||
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
|
||||
|
||||
const heroCourse = computed(() => enrolledCourses.value[0] || null);
|
||||
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-[#F8F9FA] min-h-screen font-inter pb-20">
|
||||
|
||||
|
||||
|
||||
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen font-inter pb-20 transition-colors duration-300">
|
||||
<div class="container mx-auto px-6 md:px-12 space-y-16 mt-10">
|
||||
<!-- 1. Dashboard Hero Banner (Refined) -->
|
||||
<section class="relative overflow-hidden bg-gradient-to-br from-white to-slate-50 rounded-[2rem] py-10 md:py-14 px-8 md:px-12 shadow-sm border border-slate-100 flex flex-col items-center text-center">
|
||||
<!-- 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">
|
||||
<!-- 1. Dashboard Hero Banner (Refined) -->
|
||||
<section
|
||||
class="relative overflow-hidden bg-gradient-to-br from-white to-slate-50 dark:from-slate-900 dark:to-slate-950 rounded-[2rem] py-10 md:py-14 px-8 md:px-12 shadow-sm border border-slate-100 dark:border-slate-800 flex flex-col items-center text-center transition-colors duration-300"
|
||||
>
|
||||
<!-- Subtle Decorative Elements -->
|
||||
<div
|
||||
class="absolute top-[-20%] left-[-10%] w-[300px] h-[300px] bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-3xl -z-10"
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-[-20%] right-[-10%] w-[300px] h-[300px] bg-indigo-500/5 dark:bg-indigo-500/10 rounded-full blur-3xl -z-10"
|
||||
/>
|
||||
|
||||
|
||||
<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 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 dark:text-white leading-[1.5] tracking-tight"
|
||||
>
|
||||
{{ $t("dashboard.heroTitle") }}
|
||||
<span class="inline-block text-blue-600 dark:text-blue-400 mt-1 md:mt-2">{{
|
||||
$t("dashboard.heroSubtitle")
|
||||
}}</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-slate-500 dark:text-slate-400 font-medium text-base md:text-lg max-w-xl mx-auto leading-relaxed"
|
||||
>
|
||||
{{ $t("dashboard.heroDesc") }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-4 pt-4">
|
||||
<q-btn
|
||||
unelevated
|
||||
rounded
|
||||
color="primary"
|
||||
:label="$t('dashboard.goToMyCourses')"
|
||||
class="px-8 h-[48px] font-bold no-caps shadow-lg shadow-blue-500/10 hover:-translate-y-0.5 transition-all text-sm"
|
||||
to="/dashboard/my-courses"
|
||||
/>
|
||||
<q-btn
|
||||
outline
|
||||
rounded
|
||||
color="primary"
|
||||
:label="$t('dashboard.searchNewCourses')"
|
||||
class="px-8 h-[48px] font-bold no-caps hover:bg-white dark:hover:bg-slate-800 transition-all border-1 text-sm dark:text-white dark:border-slate-600"
|
||||
style="border-width: 1.5px"
|
||||
to="/browse/discovery"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. Continue Learning Section -->
|
||||
<section v-if="enrolledCourses.length > 0">
|
||||
<div class="flex justify-between items-end mb-6">
|
||||
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white transition-colors">
|
||||
{{ $t("dashboard.continueLearningTitle") }}
|
||||
</h2>
|
||||
<NuxtLink
|
||||
to="/dashboard/my-courses"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium text-sm flex items-center gap-1 transition-colors"
|
||||
>
|
||||
{{ $t("dashboard.myCourses") }}
|
||||
<q-icon name="arrow_forward" size="16px" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Hero Card (Left) -->
|
||||
<div
|
||||
v-if="heroCourse"
|
||||
class="relative group cursor-pointer rounded-2xl overflow-hidden bg-white dark:bg-[#1e293b] shadow-sm border border-gray-100 dark:border-slate-700 hover:shadow-md transition-all h-[320px]"
|
||||
@click="
|
||||
navigateTo(`/classroom/learning?course_id=${heroCourse.id}`)
|
||||
"
|
||||
>
|
||||
<img
|
||||
:src="heroCourse.thumbnail_url"
|
||||
class="w-full h-full object-cover brightness-75 group-hover:brightness-90 transition-all duration-500"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent p-8 flex flex-col justify-end"
|
||||
>
|
||||
<h3
|
||||
class="text-white text-2xl font-bold mb-4 line-clamp-2 leading-snug shadow-black/50 drop-shadow-sm"
|
||||
>
|
||||
{{ getLocalizedText(heroCourse.title) }}
|
||||
</h3>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="w-full">
|
||||
<div class="flex justify-end text-gray-300 text-xs mb-2">
|
||||
<span>{{ heroCourse.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. Continue Learning Section -->
|
||||
<section v-if="enrolledCourses.length > 0">
|
||||
<div class="flex justify-between items-end mb-6">
|
||||
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D]">เรียนต่อกับคอร์สของคุณ</h2>
|
||||
<NuxtLink to="/dashboard/my-courses" class="text-blue-600 hover:text-blue-700 font-medium text-sm flex items-center gap-1">
|
||||
คอร์สเรียนของฉัน <q-icon name="arrow_forward" size="16px" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Hero Card (Left) -->
|
||||
<div v-if="heroCourse"
|
||||
class="relative group cursor-pointer rounded-2xl overflow-hidden bg-white shadow-sm border border-gray-100 hover:shadow-md transition-all h-[320px]"
|
||||
@click="navigateTo(`/classroom/learning?course_id=${heroCourse.id}`)">
|
||||
<img :src="heroCourse.thumbnail_url" class="w-full h-full object-cover brightness-75 group-hover:brightness-90 transition-all duration-500" />
|
||||
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent p-8 flex flex-col justify-end">
|
||||
<h3 class="text-white text-2xl font-bold mb-4 line-clamp-2 leading-snug">{{ getLocalizedText(heroCourse.title) }}</h3>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="w-full">
|
||||
<div class="flex justify-end text-gray-300 text-xs mb-2">
|
||||
<span>{{ heroCourse.progress }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-white/20 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="heroCourse.progress === 100 ? 'bg-emerald-500' : 'bg-blue-500'"
|
||||
:style="{ width: `${heroCourse.progress}%` }"></div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<span class="font-bold text-sm hover:underline transition-colors"
|
||||
:class="heroCourse.progress === 100 ? 'text-emerald-400' : 'text-white'">
|
||||
{{ heroCourse.progress === 100 ? 'เรียนอีกครั้ง' : 'เรียนต่อ' }}
|
||||
</span>
|
||||
</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 rounded-full transition-all duration-500"
|
||||
:class="course.progress === 100 ? 'bg-emerald-500' : 'bg-blue-600'"
|
||||
:style="{ width: `${course.progress}%` }"></div>
|
||||
</div>
|
||||
<div class="flex justify-end items-center text-xs">
|
||||
<span class="font-bold cursor-pointer hover:underline transition-colors"
|
||||
:class="course.progress === 100 ? 'text-emerald-600' : 'text-blue-600'"
|
||||
@click="navigateTo(`/classroom/learning?course_id=${course.id}`)">
|
||||
{{ course.progress === 100 ? 'เรียนอีกครั้ง' : 'เรียนต่อ' }}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<!-- Content when courses exist -->
|
||||
<div v-if="libraryCourses.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Course Cards -->
|
||||
<CourseCard
|
||||
v-for="course in libraryCourses"
|
||||
:key="course.id"
|
||||
v-bind="course"
|
||||
:image="course.thumbnail_url"
|
||||
hide-progress
|
||||
hide-actions
|
||||
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-blue-600 hover:bg-blue-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>
|
||||
|
||||
<!-- Empty State when no courses -->
|
||||
<div v-else class="bg-white rounded-3xl border border-dashed border-gray-200 p-12 flex flex-col items-center justify-center text-center min-h-[300px]">
|
||||
<div class="bg-blue-50 p-6 rounded-full mb-6">
|
||||
<q-icon name="school" size="48px" class="text-blue-200" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-2">ยังไม่มีคอร์สเรียนในคลัง</h3>
|
||||
<p class="text-gray-500 mb-8 max-w-md">เริ่มเรียนรู้สิ่งใหม่ๆ วันนี้ เลือกดูคอร์สเรียนที่น่าสนใจเพื่อพัฒนาทักษะของคุณ</p>
|
||||
<q-btn
|
||||
unelevated
|
||||
rounded
|
||||
no-caps
|
||||
class="bg-blue-600 text-white px-8 py-3 font-bold hover:bg-blue-700 shadow-lg shadow-blue-500/20 transition-all hover:scale-105"
|
||||
to="/browse/discovery"
|
||||
<div
|
||||
class="h-1.5 w-full bg-white/20 rounded-full overflow-hidden"
|
||||
>
|
||||
ดูคอร์สเรียนทั้งหมด
|
||||
</q-btn>
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="
|
||||
heroCourse.progress === 100
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-blue-500'
|
||||
"
|
||||
:style="{ width: `${heroCourse.progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<span
|
||||
class="font-bold text-sm hover:underline transition-colors"
|
||||
:class="
|
||||
heroCourse.progress === 100
|
||||
? 'text-emerald-400'
|
||||
: 'text-white'
|
||||
"
|
||||
>
|
||||
{{
|
||||
heroCourse.progress === 100
|
||||
? $t("dashboard.studyAgain")
|
||||
: $t("dashboard.continue")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 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"
|
||||
<!-- 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 dark:bg-[#1e293b] rounded-2xl p-4 border border-gray-100 dark:border-slate-700 shadow-sm hover:shadow-md transition-all flex gap-4 items-center"
|
||||
>
|
||||
<div class="w-32 h-20 rounded-xl overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
:src="course.thumbnail_url"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</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
|
||||
class="flex-grow min-w-0 flex flex-col justify-between h-full py-1"
|
||||
>
|
||||
<h4 class="text-gray-800 dark:text-slate-200 font-bold text-sm line-clamp-2 mb-2 transition-colors">
|
||||
{{ getLocalizedText(course.title) }}
|
||||
</h4>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div
|
||||
class="h-1.5 w-full bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden mb-2"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="
|
||||
course.progress === 100
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-blue-600'
|
||||
"
|
||||
:style="{ width: `${course.progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-end items-center text-xs">
|
||||
<span
|
||||
class="font-bold cursor-pointer hover:underline transition-colors"
|
||||
:class="
|
||||
course.progress === 100
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
"
|
||||
@click="
|
||||
navigateTo(`/classroom/learning?course_id=${course.id}`)
|
||||
"
|
||||
>
|
||||
{{
|
||||
course.progress === 100
|
||||
? $t("dashboard.studyAgain")
|
||||
: $t("dashboard.continue")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty State Placeholder if less than 2 side courses -->
|
||||
<div
|
||||
v-if="sideCourses.length < 2"
|
||||
class="flex-1 bg-gray-50 dark:bg-[#1e293b]/50 rounded-2xl border border-dashed border-gray-200 dark:border-slate-700 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm transition-colors"
|
||||
>
|
||||
{{ $t("dashboard.startNewCourse") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. Knowledge Library -->
|
||||
<section>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white mb-1 transition-colors">
|
||||
{{ $t("dashboard.knowledgeLibrary") }}
|
||||
</h2>
|
||||
<p class="text-gray-500 dark:text-slate-400 text-sm transition-colors">
|
||||
{{ $t("dashboard.libraryDesc") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content when courses exist -->
|
||||
<div
|
||||
v-if="libraryCourses.length > 0"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-6"
|
||||
>
|
||||
<!-- Course Cards -->
|
||||
<CourseCard
|
||||
v-for="course in libraryCourses"
|
||||
:key="course.id"
|
||||
v-bind="course"
|
||||
:image="course.thumbnail_url"
|
||||
hide-progress
|
||||
hide-actions
|
||||
class="h-full md:col-span-1"
|
||||
/>
|
||||
|
||||
<!-- CTA Card (Large) -->
|
||||
<div
|
||||
class="bg-white dark:bg-[#1e293b] rounded-3xl border border-gray-100 dark:border-slate-700 shadow-sm p-8 flex flex-col items-center justify-center text-center h-full min-h-[300px] hover:shadow-md transition-all group"
|
||||
>
|
||||
<p class="text-gray-600 dark:text-slate-300 font-medium mb-6 mt-4 transition-colors">
|
||||
{{ $t("dashboard.chooseLibrary") }}
|
||||
</p>
|
||||
<q-btn
|
||||
flat
|
||||
rounded
|
||||
no-caps
|
||||
class="text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 px-6 py-2 font-bold group-hover:scale-105 transition-transform"
|
||||
to="/dashboard/my-courses"
|
||||
>
|
||||
{{ $t("dashboard.viewAll") }}
|
||||
<q-icon name="arrow_forward" size="18px" class="ml-2" />
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State when no courses -->
|
||||
<div
|
||||
v-else
|
||||
class="bg-white dark:bg-[#1e293b] rounded-3xl border border-dashed border-gray-200 dark:border-slate-700 p-12 flex flex-col items-center justify-center text-center min-h-[300px] transition-colors"
|
||||
>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-full mb-6 transition-colors">
|
||||
<q-icon name="school" size="48px" class="text-blue-200 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-800 dark:text-white mb-2 transition-colors">
|
||||
{{ $t("dashboard.emptyLibraryTitle") }}
|
||||
</h3>
|
||||
<p class="text-gray-500 dark:text-slate-400 mb-8 max-w-md transition-colors">
|
||||
{{ $t("dashboard.emptyLibraryDesc") }}
|
||||
</p>
|
||||
<q-btn
|
||||
unelevated
|
||||
rounded
|
||||
no-caps
|
||||
class="bg-blue-600 text-white px-8 py-3 font-bold hover:bg-blue-700 shadow-lg shadow-blue-500/20 transition-all hover:scale-105"
|
||||
to="/browse/discovery"
|
||||
>
|
||||
{{ $t("dashboard.viewAllCourses") }}
|
||||
</q-btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. Recommended Courses -->
|
||||
<section class="pb-20">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white text-left transition-colors">
|
||||
{{ $t("dashboard.recommendedCourses") }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Recommended Grid (3 columns) -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in"
|
||||
>
|
||||
<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 dark:text-slate-500">{{ $t("dashboard.noRecommended") }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -304,11 +409,17 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
|
|||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.q-btn) {
|
||||
text-transform: none; /* Prevent uppercase in Q-Btns */
|
||||
text-transform: none; /* Prevent uppercase in Q-Btns */
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ onMounted(async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-container bg-[#F8F9FA] dark:bg-[#020617] min-h-screen transition-colors duration-300">
|
||||
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
|
|
@ -212,7 +212,7 @@ onMounted(async () => {
|
|||
flat
|
||||
round
|
||||
icon="arrow_back"
|
||||
class="dark:text-white"
|
||||
class="text-slate-600 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
@click="toggleEdit(false)"
|
||||
/>
|
||||
<div class="flex items-start gap-4">
|
||||
|
|
@ -231,7 +231,7 @@ onMounted(async () => {
|
|||
unelevated
|
||||
rounded
|
||||
color="primary"
|
||||
class="font-bold"
|
||||
class="font-bold shadow-lg shadow-blue-500/20"
|
||||
icon="edit"
|
||||
:label="$t('profile.editProfile')"
|
||||
@click="toggleEdit(true)"
|
||||
|
|
@ -247,11 +247,12 @@ onMounted(async () => {
|
|||
<q-spinner size="3rem" color="primary" />
|
||||
</div>
|
||||
|
||||
<div v-else class="max-w-4xl mx-auto">
|
||||
<!-- Unified Premium Container -->
|
||||
<div class="card-premium overflow-hidden fade-in min-h-[600px] flex flex-col">
|
||||
<div v-else class="max-w-4xl mx-auto pb-20">
|
||||
|
||||
<!-- VIEW MODE: Premium Card with Banner -->
|
||||
<div v-if="!isEditing" class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-slate-700/50 rounded-3xl shadow-xl dark:shadow-none overflow-hidden fade-in min-h-[500px] flex flex-col transition-colors duration-300">
|
||||
|
||||
<!-- Part 1: Identity Header (Banner & Avatar) -->
|
||||
<!-- Identity Header (Banner & Avatar) -->
|
||||
<div class="relative">
|
||||
<div class="h-40 bg-gradient-to-r from-blue-700 via-blue-600 to-indigo-700 relative overflow-hidden">
|
||||
<!-- Abstract Patterns -->
|
||||
|
|
@ -261,14 +262,14 @@ onMounted(async () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-8 md:px-12 flex flex-col md:flex-row items-center md:items-end gap-8 md:gap-12 -mt-12 pb-8 border-b border-slate-100 dark:border-white/5 relative z-10">
|
||||
<div class="px-8 md:px-12 flex flex-col md:flex-row items-center md:items-end gap-8 md:gap-12 -mt-12 pb-8 relative z-10">
|
||||
<div class="relative group flex-shrink-0">
|
||||
<UserAvatar
|
||||
:photo-u-r-l="userData.photoURL"
|
||||
:first-name="userData.firstName"
|
||||
:last-name="userData.lastName"
|
||||
size="140"
|
||||
class="border-[6px] border-white dark:border-slate-800 shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-900"
|
||||
class="border-[6px] border-white dark:border-[#1e293b] shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-800 transition-colors duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -277,98 +278,93 @@ onMounted(async () => {
|
|||
{{ userData.firstName }} {{ userData.lastName }}
|
||||
</h2>
|
||||
<div class="flex flex-wrap items-center justify-center md:justify-start gap-4">
|
||||
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-white/5 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-white/5">
|
||||
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-slate-800/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-slate-700">
|
||||
<q-icon name="alternate_email" size="xs" class="text-blue-500" />
|
||||
<span class="text-sm">{{ userData.email }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-white/5 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-white/5">
|
||||
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-slate-800/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-slate-700">
|
||||
<q-icon name="verified_user" size="xs" :class="userData.emailVerifiedAt ? 'text-green-500' : 'text-amber-500'" />
|
||||
<span class="text-sm">{{ userData.emailVerifiedAt ? 'ยืนยันแล้ว' : 'รอการยืนยัน' }}</span>
|
||||
<span class="text-sm">{{ userData.emailVerifiedAt ? $t('profile.emailVerified') : $t('profile.verifyEmail') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Part 2: Interactive Controls & Content -->
|
||||
<!-- View Details Content -->
|
||||
<div class="p-8 md:p-12 flex-grow">
|
||||
|
||||
<!-- Tab Selector (Segmented Pill) -->
|
||||
<div v-if="isEditing" class="flex justify-center mb-12">
|
||||
<div class="bg-slate-100 dark:bg-slate-800/50 p-1.5 rounded-2xl flex items-center gap-1 border border-slate-200 dark:border-white/5 shadow-inner">
|
||||
<div class="max-w-3xl mx-auto h-full fade-in">
|
||||
<h3 class="text-sm font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest flex items-center gap-2 mb-8">
|
||||
<span class="w-2 h-2 bg-blue-600 rounded-full"></span> {{ $t('profile.accountDetails') }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div class="flex items-center gap-4 group">
|
||||
<div class="w-12 h-12 rounded-2xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform">
|
||||
<q-icon name="smartphone" size="24px" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-0.5">{{ $t('profile.phone') }}</div>
|
||||
<div class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">{{ userData.phone || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 group">
|
||||
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-900/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 group-hover:scale-110 transition-transform">
|
||||
<q-icon name="calendar_today" size="24px" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-0.5">{{ $t('profile.joinedAt') }}</div>
|
||||
<div class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">{{ formatDate(userData.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EDIT MODE: Tabs and Forms (Clean Layout) -->
|
||||
<div v-else class="fade-in">
|
||||
<!-- Tab Selector -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="bg-white dark:bg-[#1e293b] p-1.5 rounded-2xl flex items-center gap-1 border border-slate-200 dark:border-slate-700 shadow-sm">
|
||||
<button
|
||||
@click="activeTab = 'general'"
|
||||
class="px-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'"
|
||||
class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
|
||||
:class="activeTab === 'general' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 shadow-sm scale-100' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 scale-95 opacity-70'"
|
||||
>
|
||||
<q-icon name="person_outline" size="18px" /> ข้อมูลทั่วไป
|
||||
<q-icon name="person_outline" size="18px" /> {{ $t('profile.generalInfo') }}
|
||||
</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'"
|
||||
class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
|
||||
:class="activeTab === 'security' ? 'bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 shadow-sm scale-100' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 scale-95 opacity-70'"
|
||||
>
|
||||
<q-icon name="lock_open" size="18px" /> ความปลอดภัย
|
||||
<q-icon name="lock_open" size="18px" /> {{ $t('profile.security') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Switching -->
|
||||
<div class="max-w-3xl mx-auto h-full">
|
||||
<template v-if="!isEditing">
|
||||
<div class="fade-in">
|
||||
<h3 class="text-sm font-black text-slate-400 uppercase tracking-widest flex items-center gap-2 mb-8">
|
||||
<span class="w-2 h-2 bg-blue-600 rounded-full"></span> รายละเอียดบัญชี
|
||||
</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"
|
||||
: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>
|
||||
|
||||
<!-- Edit Content -->
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div v-if="activeTab === 'general'" class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-slate-700/50 rounded-3xl shadow-xl dark:shadow-none p-6 md:p-10">
|
||||
<ProfileEditForm
|
||||
v-model="userData"
|
||||
:loading="isProfileSaving"
|
||||
:verifying="isSendingVerify"
|
||||
@submit="handleUpdateProfile"
|
||||
@upload="handleFileUpload"
|
||||
@verify="handleSendVerifyEmail"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-slate-700/50 rounded-3xl shadow-xl dark:shadow-none p-6 md:p-10">
|
||||
<PasswordChangeForm
|
||||
v-model="passwordForm"
|
||||
:loading="isPasswordSaving"
|
||||
@submit="handleUpdatePassword"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -379,57 +375,7 @@ onMounted(async () => {
|
|||
color: white;
|
||||
}
|
||||
|
||||
.card-premium {
|
||||
background-color: white;
|
||||
border-color: #e2e8f0;
|
||||
border-radius: 1.5rem;
|
||||
border-width: 1px;
|
||||
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .card-premium {
|
||||
background-color: #1e293b;
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.info-group .label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #64748b;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dark .info-group .label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.info-group .value {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.dark .info-group .value {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.premium-q-input :deep(.q-field__control) {
|
||||
border-radius: 12px;
|
||||
}
|
||||
.dark .premium-q-input :deep(.q-field__control) {
|
||||
background: #0f172a;
|
||||
}
|
||||
.dark .premium-q-input :deep(.q-field__native) {
|
||||
color: white;
|
||||
}
|
||||
.dark .premium-q-input :deep(.q-field__label) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
/* Removed card-premium and dark mode overrides as we used utility classes */
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.4s ease-out forwards;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue