feat: Add course discovery page, course card component, and dashboard index page.
This commit is contained in:
parent
62e9c9c566
commit
6d31cc2e45
3 changed files with 149 additions and 334 deletions
|
|
@ -1,47 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file CourseCard.vue
|
||||
* @description Card component for displaying course information.
|
||||
* Supports various states: default, in-progress, completed.
|
||||
* Handles action events (view, continue, certificate).
|
||||
* @description Standardized Course Card Component.
|
||||
* Usage: <CourseCard :id="1" title="..." ... />
|
||||
*/
|
||||
|
||||
interface CourseCardProps {
|
||||
/** Course ID for navigation */
|
||||
id?: number
|
||||
/** Course Title */
|
||||
title: string | { th: string; en: string }
|
||||
/** Difficulty Level (Beginner, Intermediate, etc.) */
|
||||
category?: string
|
||||
level?: string
|
||||
/** Visual type for level badge (color coding) */
|
||||
levelType?: 'neutral' | 'warning' | 'success'
|
||||
/** Course Price (e.g., 'Free', '฿990') */
|
||||
price?: string
|
||||
/** Short description */
|
||||
description?: string | { th: string; en: string }
|
||||
/** Rating score (e.g., '4.8') */
|
||||
rating?: string
|
||||
/** Number of lessons */
|
||||
lessons?: string
|
||||
/** Learning progress percentage (0-100) */
|
||||
duration?: string
|
||||
progress?: number
|
||||
/** Whether the course is completed */
|
||||
completed?: boolean
|
||||
/** Show 'View Details' button */
|
||||
showViewDetails?: boolean
|
||||
/** Show 'Continue Learning' button */
|
||||
showContinue?: boolean
|
||||
/** Show 'Download Certificate' button (if completed) */
|
||||
showCertificate?: boolean
|
||||
/** Show 'Study Again' button (if completed) */
|
||||
showStudyAgain?: boolean
|
||||
/** URL for course thumbnail image */
|
||||
image?: string
|
||||
/** Loading state for actions */
|
||||
loading?: boolean
|
||||
|
||||
// Action Flags
|
||||
showViewDetails?: boolean
|
||||
showContinue?: boolean
|
||||
showCertificate?: boolean
|
||||
showStudyAgain?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<CourseCardProps>()
|
||||
const props = withDefaults(defineProps<CourseCardProps>(), {
|
||||
showViewDetails: true,
|
||||
category: 'General',
|
||||
image: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewDetails: []
|
||||
|
|
@ -49,7 +39,6 @@ const emit = defineEmits<{
|
|||
viewCertificate: []
|
||||
}>()
|
||||
|
||||
// Helper to get localized text
|
||||
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
|
||||
if (!text) return ''
|
||||
if (typeof text === 'string') return text
|
||||
|
|
@ -61,219 +50,106 @@ const displayDescription = computed(() => getLocalizedText(props.description))
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card course-card group">
|
||||
<!-- Thumbnail -->
|
||||
<div class="thumbnail-wrapper relative h-44 overflow-hidden rounded-t-[1.5rem]">
|
||||
<div class="group relative flex flex-col bg-white dark:bg-[#1e293b] rounded-3xl overflow-hidden border border-slate-100 dark:border-slate-800 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full">
|
||||
|
||||
<!-- Thumbnail Section -->
|
||||
<div class="relative w-full aspect-video overflow-hidden">
|
||||
<img
|
||||
v-if="image"
|
||||
:src="image"
|
||||
:src="image"
|
||||
:alt="displayTitle"
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
>
|
||||
<div v-else class="w-full h-full bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-700 dark:to-slate-800 group-hover:scale-110 transition-transform duration-700"></div>
|
||||
<div v-if="completed" class="absolute inset-0 bg-emerald-600/60 backdrop-blur-[2px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<span class="text-white bg-emerald-500 w-12 h-12 rounded-full flex items-center justify-center shadow-lg transform scale-0 group-hover:scale-100 transition-transform duration-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="20 20 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<div v-else class="w-full h-full bg-slate-200 dark:bg-slate-800 flex items-center justify-center">
|
||||
<q-icon name="image" size="48px" class="text-slate-300 dark:text-slate-700" />
|
||||
</div>
|
||||
|
||||
<!-- Overlays -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent"></div>
|
||||
|
||||
<!-- Category Badge -->
|
||||
<div class="absolute top-4 left-4">
|
||||
<span class="px-3 py-1 bg-white/90 dark:bg-slate-900/90 backdrop-blur-sm text-[10px] font-black uppercase tracking-widest rounded-lg shadow-sm text-slate-800 dark:text-slate-200">
|
||||
{{ category }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Completed Badge -->
|
||||
<div v-if="completed" class="absolute inset-0 bg-emerald-900/40 backdrop-blur-[2px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<span class="bg-emerald-500 text-white w-12 h-12 rounded-full flex items-center justify-center shadow-lg transform scale-75 group-hover:scale-100 transition-transform">
|
||||
<q-icon name="check" size="24px" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Badges overlay -->
|
||||
<div class="absolute top-4 left-4 flex gap-2">
|
||||
<span v-if="level" :class="`status-pill status-${levelType || 'neutral'} shadow-sm text-[9px]`">
|
||||
{{ level }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 flex flex-col flex-1">
|
||||
|
||||
<!-- Content Section -->
|
||||
<div class="p-6 flex flex-col flex-grow">
|
||||
<!-- Meta Info (Lessons/Duration) -->
|
||||
<div class="flex items-center gap-3 text-xs font-bold text-slate-400 dark:text-slate-500 mb-3 uppercase tracking-wider">
|
||||
<span v-if="lessons" class="flex items-center gap-1">
|
||||
<q-icon name="menu_book" size="14px" /> {{ lessons }} {{ $t('course.lessonsUnit') }}
|
||||
</span>
|
||||
<span v-if="duration" class="flex items-center gap-1">
|
||||
<q-icon name="schedule" size="14px" /> {{ duration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="font-black text-lg mb-2 line-clamp-1 text-slate-900 dark:text-white group-hover:text-blue-400 transition-colors">{{ displayTitle }}</h3>
|
||||
|
||||
<h3 class="text-lg font-black text-slate-900 dark:text-white mb-2 line-clamp-2 leading-tight group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||
{{ displayTitle }}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="displayDescription" class="text-sm mb-6 line-clamp-2 leading-relaxed text-slate-600 dark:text-slate-400">{{ displayDescription }}</div>
|
||||
|
||||
<!-- Rating & Lessons -->
|
||||
<div v-if="rating || lessons" class="flex items-center gap-4 text-[11px] font-bold mb-6 uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||
<span v-if="rating" class="flex items-center gap-1"><span class="text-amber-400 text-sm">★</span> {{ rating }}</span>
|
||||
<span v-if="lessons" class="flex items-center gap-1">{{ lessons }} {{ $t('course.lessonsUnit') }}</span>
|
||||
</div>
|
||||
<p v-if="displayDescription" class="text-sm text-slate-500 dark:text-slate-400 line-clamp-2 mb-6">
|
||||
{{ displayDescription }}
|
||||
</p>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div v-if="progress !== undefined && !completed" class="mb-8 p-3 rounded-2xl" style="background-color: rgba(148, 163, 184, 0.1);">
|
||||
<div class="flex justify-between items-center text-[10px] font-black uppercase tracking-widest mb-1.5">
|
||||
<span class="text-slate-950 dark:text-slate-300">{{ $t('course.progress') }}</span>
|
||||
<span class="text-blue-600">{{ progress }}%</span>
|
||||
<div class="mt-auto pt-4">
|
||||
<!-- Progress Bar -->
|
||||
<div v-if="progress !== undefined && !completed" class="mb-4">
|
||||
<div class="flex justify-between text-[10px] font-bold uppercase mb-1">
|
||||
<span class="text-slate-500">{{ $t('course.progress') }}</span>
|
||||
<span class="text-blue-600">{{ progress }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-blue-600 rounded-full transition-all duration-500" :style="{ width: `${progress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-1.5 bg-slate-300 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-blue-600 shadow-[0_0_10px_rgba(37,99,235,0.4)] rounded-full transition-all duration-1000" :style="{ width: `${progress}%` }"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Badge -->
|
||||
<div v-if="completed" class="mb-6">
|
||||
<span class="status-pill status-success text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2">
|
||||
<span class="text-sm">✓</span> {{ $t('course.completed') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<q-btn
|
||||
v-if="showViewDetails"
|
||||
unelevated
|
||||
rounded
|
||||
class="w-full mt-auto font-black"
|
||||
style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: white;"
|
||||
:label="$t('menu.viewDetails')"
|
||||
@click="emit('viewDetails')"
|
||||
/>
|
||||
|
||||
<q-btn
|
||||
v-if="showContinue"
|
||||
:to="`/classroom/learning?course_id=${id}`"
|
||||
unelevated
|
||||
rounded
|
||||
class="w-full mt-auto font-black shadow-lg shadow-blue-600/20"
|
||||
style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: white;"
|
||||
:label="$t('course.continueLearning')"
|
||||
/>
|
||||
|
||||
<div v-if="completed && (showCertificate || showStudyAgain)" class="flex flex-col gap-2 mt-auto">
|
||||
<!-- Action Buttons -->
|
||||
<q-btn
|
||||
v-if="showStudyAgain"
|
||||
:to="`/classroom/learning?course_id=${id}`"
|
||||
unelevated
|
||||
rounded
|
||||
class="w-full font-black"
|
||||
style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: white;"
|
||||
:label="$t('course.studyAgain')"
|
||||
v-if="showViewDetails && !completed && !progress"
|
||||
unelevated
|
||||
rounded
|
||||
class="w-full font-bold shadow-blue-200 dark:shadow-none bg-blue-600 text-white"
|
||||
:label="$t('menu.viewDetails')"
|
||||
:to="`/course/${id}`"
|
||||
/>
|
||||
|
||||
<q-btn
|
||||
v-if="showCertificate"
|
||||
unelevated
|
||||
rounded
|
||||
class="w-full font-black shadow-lg shadow-emerald-600/20"
|
||||
style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white;"
|
||||
:label="$t('course.downloadCertificate')"
|
||||
:loading="loading"
|
||||
@click="emit('viewCertificate')"
|
||||
v-if="showContinue || (progress && !completed)"
|
||||
unelevated
|
||||
rounded
|
||||
class="w-full font-bold bg-slate-900 dark:bg-white text-white dark:text-slate-900"
|
||||
:label="$t('course.continueLearning')"
|
||||
:to="`/classroom/learning?course_id=${id}`"
|
||||
/>
|
||||
|
||||
<div v-if="completed" class="space-y-2">
|
||||
<q-btn
|
||||
v-if="showCertificate"
|
||||
outline
|
||||
rounded
|
||||
class="w-full font-bold text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-900"
|
||||
:label="$t('course.downloadCertificate')"
|
||||
@click="emit('viewCertificate')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.course-card {
|
||||
padding: 0;
|
||||
border-radius: 2rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.course-card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
/* Remove explicit dark mode block as default variables handle it */
|
||||
/* But keep specific tweaks if needed */
|
||||
|
||||
.btn-premium-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
padding: 0.875rem;
|
||||
border-radius: 1rem;
|
||||
font-weight: 800;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.btn-premium-primary:hover {
|
||||
filter: brightness(110%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-premium-secondary {
|
||||
background: transparent;
|
||||
color: #1e293b;
|
||||
padding: 0.875rem;
|
||||
border-radius: 1rem;
|
||||
font-weight: 800;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-premium-secondary:hover {
|
||||
background: var(--bg-body);
|
||||
border-color: #94a3b8;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
:global(.dark) .btn-premium-secondary {
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
color: #22d3ee !important;
|
||||
border-color: rgba(34, 211, 238, 0.3) !important;
|
||||
}
|
||||
|
||||
:global(.dark) .btn-premium-secondary:hover {
|
||||
background: rgba(34, 211, 238, 0.2);
|
||||
border-color: #22d3ee !important;
|
||||
color: #67e8f9 !important;
|
||||
}
|
||||
|
||||
.btn-premium-success {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
padding: 0.875rem;
|
||||
border-radius: 1rem;
|
||||
font-weight: 800;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(229, 231, 235, 0.5);
|
||||
}
|
||||
|
||||
:global(.dark) .glass {
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
@apply px-3 py-1 rounded-full font-black uppercase tracking-wider;
|
||||
}
|
||||
.status-success {
|
||||
@apply bg-emerald-100 text-emerald-700;
|
||||
}
|
||||
.status-warning {
|
||||
@apply bg-amber-100 text-amber-700;
|
||||
}
|
||||
.status-neutral {
|
||||
@apply bg-slate-100 text-slate-700;
|
||||
}
|
||||
|
||||
:global(.dark) .status-success {
|
||||
@apply bg-emerald-900/40 text-emerald-300;
|
||||
}
|
||||
|
||||
:global(.dark) .status-warning {
|
||||
@apply bg-amber-900/40 text-amber-300;
|
||||
}
|
||||
|
||||
:global(.dark) .status-neutral {
|
||||
@apply bg-slate-700 text-slate-300;
|
||||
}
|
||||
/* Scoped overrides if needed */
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue