feat: implement course discovery page with category filtering sidebar and course detail view.
This commit is contained in:
parent
088bbf4b1b
commit
efb50a1ddb
3 changed files with 80 additions and 201 deletions
|
|
@ -23,129 +23,80 @@ const getLocalizedText = (text: any) => {
|
||||||
const currentLocale = locale.value as 'th' | 'en';
|
const currentLocale = locale.value as 'th' | 'en';
|
||||||
return text[currentLocale] || text.th || text.en || "";
|
return text[currentLocale] || text.th || text.en || "";
|
||||||
};
|
};
|
||||||
|
const toggleCategory = (id: number) => {
|
||||||
|
const current = [...props.modelValue];
|
||||||
|
const index = current.indexOf(id);
|
||||||
|
if (index > -1) {
|
||||||
|
current.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
current.push(id);
|
||||||
|
}
|
||||||
|
emit("update:modelValue", current);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="category-sidebar-root border rounded-2xl overflow-hidden shadow-sm">
|
<div class="bg-white/60 dark:!bg-[#0f172a]/60 backdrop-blur-3xl rounded-[2.5rem] border border-slate-200 dark:border-white/10 shadow-xl shadow-blue-900/5 overflow-hidden transition-all duration-500">
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
expand-separator
|
default-opened
|
||||||
:label="`${$t('discovery.categoryTitle')} (${modelValue.length})`"
|
expand-separator
|
||||||
class="category-sidebar-expansion"
|
header-class="py-6 px-8"
|
||||||
header-class="category-sidebar-header"
|
class="group"
|
||||||
default-opened
|
|
||||||
>
|
|
||||||
<q-list class="category-sidebar-list border-t">
|
|
||||||
<q-item
|
|
||||||
v-for="cat in showAllCategories ? categories : categories.slice(0, 4)"
|
|
||||||
:key="cat.id"
|
|
||||||
clickable
|
|
||||||
v-ripple
|
|
||||||
dense
|
|
||||||
class="category-item"
|
|
||||||
>
|
>
|
||||||
<q-item-section avatar>
|
<template v-slot:header>
|
||||||
<q-checkbox
|
<div class="flex items-center gap-4 w-full">
|
||||||
:model-value="modelValue"
|
<div class="w-10 h-10 rounded-xl bg-blue-600/10 dark:bg-blue-400/10 flex items-center justify-center">
|
||||||
@update:model-value="(val) => emit('update:modelValue', val)"
|
<q-icon name="category" class="text-blue-600 dark:text-blue-400" size="20px" />
|
||||||
:val="cat.id"
|
</div>
|
||||||
color="primary"
|
<div class="flex-1">
|
||||||
dense
|
<div class="text-lg font-black text-slate-900 dark:!text-white leading-none mb-1">{{ $t('discovery.categoryTitle') }}</div>
|
||||||
class="checkbox-visible"
|
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500">{{ modelValue.length }} Selectable</div>
|
||||||
/>
|
</div>
|
||||||
</q-item-section>
|
</div>
|
||||||
<q-item-section>
|
</template>
|
||||||
<q-item-label class="category-item-label text-sm font-medium">
|
|
||||||
{{ getLocalizedText(cat.name) }}
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<!-- Show More/Less Button -->
|
<div class="px-3 pb-6 pt-2">
|
||||||
<q-item
|
<div class="space-y-1">
|
||||||
v-if="categories.length > 4"
|
<div
|
||||||
clickable
|
v-for="cat in showAllCategories ? categories : categories.slice(0, 5)"
|
||||||
v-ripple
|
:key="cat.id"
|
||||||
@click="showAllCategories = !showAllCategories"
|
class="relative group/item"
|
||||||
class="show-more-item font-bold text-sm"
|
>
|
||||||
>
|
<div
|
||||||
<q-item-section>
|
class="flex items-center gap-3 px-5 py-3 rounded-2xl transition-all cursor-pointer border border-transparent"
|
||||||
<div class="flex items-center gap-1 text-blue-600 dark:text-blue-400">
|
:class="modelValue.includes(cat.id) ? 'bg-blue-600/5 dark:bg-blue-400/5 border-blue-100 dark:border-blue-400/20 shadow-sm shadow-blue-500/5' : 'hover:bg-slate-50 dark:hover:bg-white/5'"
|
||||||
{{ showAllCategories ? $t('discovery.showLess') : $t('discovery.showMore') }}
|
@click="toggleCategory(cat.id)"
|
||||||
<svg
|
>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<q-checkbox
|
||||||
class="h-4 w-4"
|
:model-value="modelValue.includes(cat.id)"
|
||||||
fill="none"
|
@update:model-value="toggleCategory(cat.id)"
|
||||||
viewBox="0 0 24 24"
|
color="primary"
|
||||||
stroke="currentColor"
|
keep-color
|
||||||
:class="showAllCategories ? 'rotate-180' : ''"
|
dense
|
||||||
>
|
/>
|
||||||
<path
|
<span
|
||||||
stroke-linecap="round"
|
class="text-sm font-bold transition-colors truncate"
|
||||||
stroke-linejoin="round"
|
:class="modelValue.includes(cat.id) ? 'text-blue-700 dark:text-blue-400' : 'text-slate-700 dark:text-slate-400 group-hover/item:text-slate-900 dark:group-hover/item:text-white'"
|
||||||
stroke-width="2"
|
>
|
||||||
d="M19 9l-7 7-7-7"
|
{{ getLocalizedText(cat.name) }}
|
||||||
/>
|
</span>
|
||||||
</svg>
|
|
||||||
|
<!-- Active Indicator Dot -->
|
||||||
|
<div v-if="modelValue.includes(cat.id)" class="ml-auto w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400 shadow-lg shadow-blue-500/50"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Show More/Less Action -->
|
||||||
|
<div
|
||||||
|
v-if="categories.length > 5"
|
||||||
|
@click="showAllCategories = !showAllCategories"
|
||||||
|
class="mt-4 mx-2 py-3 px-5 rounded-xl border border-dashed border-slate-200 dark:border-white/10 flex items-center justify-center gap-2 cursor-pointer text-slate-500 dark:text-slate-400 hover:border-blue-300 dark:hover:border-blue-500/40 hover:text-blue-600 dark:hover:text-blue-400 transition-all font-bold text-xs uppercase tracking-widest"
|
||||||
|
>
|
||||||
|
{{ showAllCategories ? $t('discovery.showLess') : $t('discovery.showMore') }}
|
||||||
|
<q-icon :name="showAllCategories ? 'keyboard_arrow_up' : 'keyboard_arrow_down'" size="16px" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-expansion-item>
|
||||||
</q-item>
|
</div>
|
||||||
</q-list>
|
|
||||||
</q-expansion-item>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Base Styles - Rely on main.css variables */
|
|
||||||
.category-sidebar-root {
|
|
||||||
background-color: var(--bg-surface);
|
|
||||||
border-color: var(--border-color);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Internal Quasar components color management */
|
|
||||||
:deep(.category-sidebar-header),
|
|
||||||
.category-sidebar-list {
|
|
||||||
background-color: var(--bg-surface) !important;
|
|
||||||
color: var(--text-main) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Labels and Icons - use var(--text-main) but force opacity */
|
|
||||||
:deep(.q-item__label),
|
|
||||||
:deep(.q-icon) {
|
|
||||||
color: var(--text-main) !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-item-label {
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ✅ DARK MODE SPECIFIC OVERRIDES using :global(.dark) as recommended */
|
|
||||||
:global(.dark) .category-item-label {
|
|
||||||
color: #f8fafc !important; /* Forces slate-50 in dark mode */
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) :deep(.category-sidebar-header *) {
|
|
||||||
color: #f8fafc !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover effects */
|
|
||||||
.category-item:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .category-item:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkbox Label handling */
|
|
||||||
:deep(.q-checkbox__label) {
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Show More button fix */
|
|
||||||
.show-more-item {
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,13 @@ const totalPages = ref(1);
|
||||||
const itemsPerPage = 12;
|
const itemsPerPage = 12;
|
||||||
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const { fetchCategories } = useCategory();
|
const { fetchCategories } = useCategory();
|
||||||
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse();
|
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse();
|
||||||
|
|
||||||
|
|
||||||
// 2. Computed Properties
|
// 2. Computed Properties
|
||||||
const sortOption = ref(computed(() => t('discovery.sortRecent')));
|
const sortOption = ref(t('discovery.sortRecent'));
|
||||||
const sortOptions = computed(() => [t('discovery.sortRecent')]);
|
const sortOptions = computed(() => [t('discovery.sortRecent')]);
|
||||||
|
|
||||||
const filteredCourses = computed(() => {
|
const filteredCourses = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ definePageMeta({
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
// ดึง courseId จาก URL params (แปลงเป็น integer)
|
// ดึง courseId จาก URL params (แปลงเป็น integer)
|
||||||
const courseId = computed(() => parseInt(route.params.id as string))
|
const courseId = computed(() => parseInt(route.params.id as string))
|
||||||
|
const { currentUser } = useAuth()
|
||||||
const { fetchCourseById, enrollCourse, getLocalizedText } = useCourse()
|
const { fetchCourseById, enrollCourse, getLocalizedText } = useCourse()
|
||||||
|
|
||||||
// ใช้ useAsyncData ดึงข้อมูลคอร์ส Server-side rendering (SSR)
|
// ใช้ useAsyncData ดึงข้อมูลคอร์ส Server-side rendering (SSR)
|
||||||
|
|
@ -65,85 +66,13 @@ useHead({
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
|
|
||||||
|
|
||||||
<div v-if="course" class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
<div v-if="course" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<CourseDetailView
|
||||||
<div class="lg:col-span-8">
|
:course="course"
|
||||||
<div class="relative aspect-video bg-slate-900 rounded-2xl overflow-hidden shadow-lg mb-8 group cursor-pointer border border-slate-200 dark:border-slate-700">
|
:user="currentUser"
|
||||||
<div v-if="course.thumbnail_url" class="absolute inset-0">
|
@back="navigateTo('/browse/discovery')"
|
||||||
<img :src="course.thumbnail_url" class="w-full h-full object-cover opacity-90 group-hover:opacity-75 transition-opacity" />
|
@enroll="handleEnroll"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white mb-6 leading-tight">{{ getLocalizedText(course.title) }}</h1>
|
|
||||||
|
|
||||||
<div class="prose prose-slate dark:prose-invert max-w-none mb-10 text-slate-600 dark:text-slate-300">
|
|
||||||
<p class="text-lg leading-relaxed">{{ getLocalizedText(course.description) }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-3xl p-6 md:p-8 shadow-sm border border-slate-100">
|
|
||||||
<h3 class="font-bold text-xl text-slate-900 dark:text-black mb-6 flex items-center gap-2">
|
|
||||||
<span class="w-1 h-6 bg-blue-500 rounded-full"></span>
|
|
||||||
{{ $t('course.courseContent') }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div v-if="course.chapters && course.chapters.length > 0">
|
|
||||||
<div v-for="(chapter, cIdx) in course.chapters" :key="cIdx" class="mb-6 last:mb-0">
|
|
||||||
<div class="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl mb-2 border border-slate-100 dark:border-slate-600">
|
|
||||||
<span class="font-bold text-slate-800 dark:text-slate-100">{{ getLocalizedText(chapter.title) }}</span>
|
|
||||||
<span class="text-xs font-semibold px-2 py-1 bg-white dark:bg-slate-600 rounded text-slate-500 dark:text-slate-300 border border-slate-200 dark:border-slate-500">
|
|
||||||
{{ chapter.lessons ? chapter.lessons.length : 0 }} {{ $t('course.lessons') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pl-4 pr-2 space-y-1">
|
|
||||||
<div v-for="(lesson, lIdx) in chapter.lessons" :key="lIdx" class="flex items-center justify-between py-2.5 px-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors group">
|
|
||||||
<div class="flex items-center gap-3 overflow-hidden">
|
|
||||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-slate-100 dark:bg-slate-700 text-slate-400 dark:text-slate-500 text-[10px] flex items-center justify-center font-bold">
|
|
||||||
{{ Number(lIdx) + 1 }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-slate-600 dark:text-slate-300 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(lesson.title) }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-slate-400 flex-shrink-0 font-mono">{{ lesson.duration_minutes || 0 }}:00</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="text-center py-8 text-slate-400 dark:text-black font-medium">
|
|
||||||
<p>{{ $t('course.noContent') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:col-span-4">
|
|
||||||
<div class="sticky top-24 bg-white rounded-3xl p-6 md:p-8 shadow-lg border border-slate-100">
|
|
||||||
<div class="mb-8">
|
|
||||||
<span class="text-sm text-slate-400 line-through mr-3" v-if="course.original_price">{{ course.original_price }}</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<h2 class="text-4xl font-black text-blue-600 dark:text-blue-400 tracking-tight">{{ course.is_free ? 'ฟรี' : course.price }}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="handleEnroll"
|
|
||||||
:disabled="isEnrolling"
|
|
||||||
class="w-full py-4 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white rounded-xl font-bold text-lg shadow-lg shadow-blue-600/30 transition-all hover:translate-y-[-2px] mb-6 flex items-center justify-center disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
|
|
||||||
>
|
|
||||||
<span v-if="isEnrolling" class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"></span>
|
|
||||||
{{ isEnrolling ? 'กำลังลงทะเบียน...' : $t('course.enrollNow') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="space-y-4 text-sm text-slate-600 dark:text-black pt-6 border-t border-slate-100">
|
|
||||||
<div class="flex justify-between py-2">
|
|
||||||
<span>{{ $t('course.certificate') }}</span>
|
|
||||||
<span class="font-bold text-slate-900 dark:text-black">{{ course.have_certificate ? $t('course.available') : '-' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading / Error State -->
|
<!-- Loading / Error State -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue