feat: Add course discovery page, course card component, and dashboard index page.

This commit is contained in:
supalerk-ar66 2026-02-09 14:20:11 +07:00
parent 62e9c9c566
commit 6d31cc2e45
3 changed files with 149 additions and 334 deletions

View file

@ -130,19 +130,19 @@ onMounted(() => {
<template>
<div class="page-container">
<!-- CATALOG VIEW: Browse courses -->
<div v-if="!showDetail">
<!-- Top Header Area -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-10">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<!-- Title -->
<h1 class="text-3xl font-black text-slate-900 dark:text-white">
<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>
{{ $t('discovery.title') }}
</h1>
<!-- Right Side: Search & Sort -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 w-full md:w-auto">
<!-- Search Input -->
<q-input
v-model="searchQuery"
@ -150,22 +150,21 @@ onMounted(() => {
outlined
rounded
:placeholder="$t('discovery.searchPlaceholder')"
class="disc-search w-full md:w-64"
class="w-full md:w-72 bg-white dark:bg-slate-800 search-input"
bg-color="transparent"
>
<template v-slot:append>
<template v-slot:prepend>
<q-icon name="search" class="text-slate-400" />
</template>
</q-input>
</div>
</div>
<!-- Main Layout: Sidebar + Grid -->
<div class="flex flex-col lg:flex-row gap-8 items-start">
<div class="flex flex-col lg:flex-row gap-8">
<!-- LEFT SIDEBAR: Category Filter -->
<div class="w-full lg:w-64 flex-shrink-0 lg:sticky lg:top-24">
<div class="w-full lg:w-64 flex-shrink-0 lg:sticky lg:top-24 z-10">
<CategorySidebar
:categories="categories"
v-model="selectedCategoryIds"
@ -174,48 +173,45 @@ onMounted(() => {
<!-- RIGHT CONTENT: Course Grid -->
<div class="flex-1 w-full">
<div v-if="filteredCourses.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<CourseCard
v-for="course in filteredCourses"
:key="course.id"
:title="course.title"
:price="course.price"
:description="course.description"
:rating="course.rating"
:lessons="course.lessons"
:image="course.thumbnail_url"
show-view-details
@view-details="selectCourse(course.id)"
/>
</div>
<template v-if="filteredCourses.length > 0">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<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 mt-12 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>
<!-- Pagination Controls -->
<div v-if="totalPages > 1" class="flex justify-center mt-12 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>
</template>
<!-- Empty State -->
<div
v-else
class="flex flex-col items-center justify-center py-20 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700"
>
<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:underline" @click="searchQuery = ''; selectedCategoryIds = []">
<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>
@ -231,7 +227,7 @@ onMounted(() => {
@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="28px" class="transition-transform group-hover:-translate-x-2" />
<q-icon name="arrow_back" size="24px" class="transition-transform group-hover:-translate-x-1" />
{{ $t('discovery.backToCatalog') }}
</button>
@ -251,55 +247,11 @@ onMounted(() => {
</template>
<style scoped>
/* =========================
Discovery: Quasar Field Fix
- จำกดผลกระทบเฉพาะชองคนหา/เรยงตาม
- focus ring (กรอบขาว 4 เหลยม)
========================= */
/* ให้หน้าตา input เป็น pill ขาวเหมือนรูป (ทั้ง Light/Dark) */
.disc-search :deep(.q-field__control),
.disc-sort :deep(.q-field__control) {
background: #ffffff !important;
border-radius: 9999px !important;
/* Standard overrides for Quasar inputs to match Tailwind theme */
.search-input :deep(.q-field__control) {
border-radius: 9999px; /* Full rounded pill */
}
/* สีตัวอักษร/placeholder/icon ให้คมชัด */
.disc-search :deep(.q-field__native),
.disc-search :deep(.q-field__input),
.disc-sort :deep(.q-field__native),
.disc-sort :deep(.q-field__input) {
color: #0f172a !important;
-webkit-text-fill-color: #0f172a !important;
}
.disc-search :deep(.q-placeholder),
.disc-sort :deep(.q-placeholder) {
color: #64748b !important; /* slate-500 */
}
.disc-search :deep(.q-icon),
.disc-sort :deep(.q-icon),
.disc-sort :deep(.q-select__dropdown-icon) {
color: #64748b !important;
}
/* ✅ ตัด “Focus outline / Focus ring” ที่เป็นกรอบสว่าง */
.disc-search :deep(.q-field__control:after),
.disc-sort :deep(.q-field__control:after) {
opacity: 0 !important; /* ตัวนี้มักเป็นเส้น/แสงตอน focus */
}
/* คุมเส้นขอบปกติให้บาง ๆ ไม่เด้งเป็นกรอบขาว */
.disc-search :deep(.q-field__control:before),
.disc-sort :deep(.q-field__control:before) {
border-color: rgba(203, 213, 225, 0.9) !important; /* slate-300 */
}
/* กัน browser outline ซ้อน */
.disc-search :deep(input:focus),
.disc-sort :deep(input:focus) {
outline: none !important;
.search-input :deep(.q-field__shadow) {
box-shadow: none !important;
}
</style>

View file

@ -55,7 +55,7 @@ onMounted(async () => {
<template>
<div class="page-container">
<!-- Welcome Header Section -->
<div class="welcome-section mb-10 overflow-hidden relative rounded-[2.5rem] p-10 md:p-14 text-white shadow-lg dark:shadow-2xl dark:shadow-blue-900/20 transition-all">
<div class="welcome-section mb-8 overflow-hidden relative rounded-3xl p-8 md:p-10 text-white shadow-lg dark:shadow-2xl dark:shadow-blue-900/20 transition-all">
<div class="relative z-10 flex flex-col md:flex-row justify-between items-center gap-8">
<div>
<h1 class="text-4xl md:text-5xl font-black mb-3 slide-up tracking-tight text-white dark:text-white">{{ $t('dashboard.welcomeTitle') }}, {{ currentUser?.firstName }}!</h1>
@ -70,35 +70,22 @@ onMounted(async () => {
</div>
<!-- Main Content Area -->
<div class="lg:col-span-12">
<div>
<!-- Section: Recommended Courses -->
<div class="mb-8">
<h2 class="text-2xl font-black flex items-center gap-3 tracking-tight text-slate-900 dark:text-white">
<span class="w-1.5 h-8 bg-emerald-500 rounded-full shadow-[0_0_15px_rgba(16,185,129,0.5)]"/>
<div class="mb-6">
<h2 class="text-xl font-black flex items-center gap-3 tracking-tight text-slate-900 dark:text-white">
<span class="w-1 h-6 bg-emerald-500 rounded-full shadow-sm shadow-emerald-500/50"/>
{{ $t('menu.recommendedCourses') }}
</h2>
</div>
<!-- Recommended Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 pb-20">
<NuxtLink v-for="(course, idx) in recommendedCourses" :key="course.id" :to="`/course/${course.id}`" class="p-0 overflow-hidden group border border-slate-200 dark:border-white/5 rounded-3xl shadow-sm dark:shadow-xl transition-all hover:-translate-y-1 block" style="background-color: var(--bg-surface);">
<div class="h-48 overflow-hidden relative rounded-t-3xl">
<img v-if="course.image" :src="course.image" :alt="course.title" class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" >
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-slate-900/20 dark:from-[#1e293b] to-transparent"/>
</div>
<div class="p-7" style="background-color: var(--bg-surface);">
<div class="text-[10px] font-black text-slate-700 dark:text-slate-500 uppercase tracking-[0.2em] mb-2">{{ course.category }}</div>
<h4 class="font-black text-xl mb-6 text-slate-900 dark:text-white group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors line-clamp-2 h-14">{{ course.title }}</h4>
<div class="flex items-center justify-between pt-4 border-t border-slate-50 dark:border-white/5">
<span v-if="course.duration" class="text-xs font-bold text-slate-700 dark:text-slate-400 flex items-center gap-2">
<q-icon name="schedule" size="14px" />
{{ course.duration }}
</span>
<div v-else />
<span class="px-4 py-2 bg-blue-600 text-white text-xs font-bold rounded-lg hover:bg-blue-700 transition-colors shadow-md">{{ $t('menu.viewDetails') }}</span>
</div>
</div>
</NuxtLink>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-20">
<CourseCard
v-for="course in recommendedCourses"
:key="course.id"
v-bind="course"
/>
</div>
<!-- Loading State for Recommended -->