feat: Add course detail page, authentication composable, and course browsing page.

This commit is contained in:
supalerk-ar66 2026-01-21 10:02:37 +07:00
parent 1e06041769
commit 327f6ec7b5
3 changed files with 230 additions and 115 deletions

View file

@ -17,54 +17,25 @@ useHead({
// Reactive state for the search input
const searchQuery = ref('')
const { fetchCourses } = useCourse()
/**
* @const courses
* @description Mock data for available courses. In a real app, this would be fetched from an API.
* Each course object contains: id, title, level, levelType (for badge color), price, description, rating, lessons.
*/
const courses = [
{
id: 1,
title: 'เบื้องต้นการออกแบบ UX/UI',
price: 'ฟรี',
description: 'เรียนรู้พื้นฐานการวาดโครงร่างและการใช้งานเครื่องมือออกแบบยุคใหม่',
rating: '4.8',
lessons: '12'
},
{
id: 2,
title: 'รูปแบบ React ขั้นสูง',
price: 'ฟรี',
description: 'เจาะลึก HOC, Hooks และการจัดการ State ขนาดใหญ่ในแอปพลิเคชัน',
rating: '4.9',
lessons: '24'
},
{
id: 3,
title: 'การตลาดดิจิทัล 101',
price: 'ฟรี',
description: 'คู่มือสมบูรณ์สำหรับการทำ SEO/SEM และการวิเคราะห์ข้อมูลผู้ใช้',
rating: '4.7',
lessons: '18'
},
{
id: 4,
title: 'Full-stack Node.js Developer',
price: 'ฟรี',
description: 'สร้าง API ที่ยืดหยุ่นและมีประสิทธิภาพด้วย Node.js และ Express',
rating: '4.9',
lessons: '32'
},
{
id: 4,
title: 'Full-stack Node.js Developer',
price: 'ฟรี',
description: 'สร้าง API ที่ยืดหยุ่นและมีประสิทธิภาพด้วย Node.js และ Express',
rating: '4.9',
lessons: '32'
// Helper to handle localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
if (!text) return ''
if (typeof text === 'string') return text
return text.th || text.en || ''
}
// Fetch courses from API
const { data: coursesResponse, error } = await useAsyncData('courses-list', () => fetchCourses())
// Computed property for courses list from API response
const courses = computed(() => {
if (coursesResponse.value?.success) {
return coursesResponse.value.data
}
]
return []
})
/**
* @computed filteredCourses
@ -72,12 +43,15 @@ const courses = [
* Checks both the course title and description (case-insensitive).
*/
const filteredCourses = computed(() => {
if (!searchQuery.value) return courses
const list = courses.value || []
if (!searchQuery.value) return list
const query = searchQuery.value.toLowerCase()
return courses.filter(c =>
c.title.toLowerCase().includes(query) ||
c.description.toLowerCase().includes(query)
)
return list.filter(c => {
const title = getLocalizedText(c.title).toLowerCase()
const desc = getLocalizedText(c.description).toLowerCase()
return title.includes(query) || desc.includes(query)
})
})
</script>
@ -146,46 +120,58 @@ const filteredCourses = computed(() => {
class="glass-card group flex flex-col h-full hover:-translate-y-2 transition-transform duration-500 slide-up"
:style="{ animationDelay: `${0.1 * (index + 1)}s` }"
>
<!-- Card Image / Placeholder Area -->
<!-- Card Image -->
<div class="h-56 bg-gradient-to-br from-slate-800 to-slate-900 relative overflow-hidden group-hover:opacity-90 transition-opacity">
<div class="absolute inset-0 flex items-center justify-center">
<img
v-if="course.thumbnail_url"
:src="course.thumbnail_url"
:alt="getLocalizedText(course.title)"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div
v-else-if="!course.thumbnail_url || true"
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-slate-800 to-slate-900"
>
<div class="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center text-5xl group-hover:scale-110 transition-transform duration-500 shadow-inner border border-white/5">
📚
</div>
</div>
<!-- Level Badge (Neutral/Warning/Success variants) -->
<!-- Level Badge (Neutral/Warning/Success variants) - Optional based on data -->
<div v-if="course.levelType" class="absolute top-4 right-4">
<!-- Add badge logic if needed -->
</div>
</div>
<!-- Card Content Body -->
<div class="p-8 flex-1 flex flex-col border-t border-white/5">
<h3 class="text-2xl font-bold text-white mb-3 leading-tight group-hover:text-blue-400 transition-colors">
{{ course.title }}
{{ getLocalizedText(course.title) }}
</h3>
<p class="text-slate-400 text-sm mb-8 line-clamp-2 leading-relaxed flex-1">
{{ course.description }}
{{ getLocalizedText(course.description) }}
</p>
<!-- Meta Information (Rating, Lessons) -->
<div class="flex items-center gap-6 mb-8 text-sm font-medium text-slate-500">
<div class="flex items-center gap-2">
<span class="text-amber-400"></span>
<span class="text-slate-300">{{ course.rating }}</span>
<span class="text-slate-300">{{ course.rating || 'N/A' }}</span>
</div>
<div class="w-1 h-1 rounded-full bg-slate-700" />
<div class="flex items-center gap-2">
<span class="opacity-70">📖</span>
<span class="text-slate-300">{{ course.lessons }} บทเรยน</span>
<span class="text-slate-300">{{ course.lessons || 0 }} บทเรยน</span>
</div>
</div>
<!-- Card Footer (Price & Action) -->
<div class="pt-6 border-t border-white/5 flex items-center justify-between mt-auto">
<span class="text-2xl font-black text-blue-400 tracking-tight">
{{ course.price }}
{{ course.is_free ? 'ฟรี' : course.price }}
</span>
<NuxtLink
to="/browse/opencovery"
:to="`/course/${course.id}`"
class="px-6 py-3 bg-white/10 hover:bg-blue-600 text-white rounded-xl text-sm font-bold transition-all border border-white/10 hover:border-blue-500/50"
>
รายละเอยด

View file

@ -0,0 +1,145 @@
<script setup lang="ts">
/**
* @file [id].vue
* @description Dynamic Course Detail Page.
* Displays detailed information about a specific course based on the ID.
*/
const route = useRoute()
const courseId = computed(() => parseInt(route.params.id as string))
const { fetchCourseById } = useCourse()
// Fetch course data
const { data: courseData, error } = await useAsyncData(`course-${courseId.value}`, () => fetchCourseById(courseId.value))
const course = computed(() => {
return courseData.value?.success ? courseData.value.data : null
})
// Helper for localization
const getLocalizedText = (text: string | { th: string; en: string } | undefined | null) => {
if (!text) return ''
if (typeof text === 'string') return text
return text.th || text.en || ''
}
definePageMeta({
layout: 'landing',
auth: false // Explicitly public? Or check logic.
})
useHead({
title: computed(() => course.value ? `${getLocalizedText(course.value.title)} - E-Learning` : 'Course Details')
})
</script>
<template>
<div class="relative min-h-screen text-slate-200 bg-slate-900 transition-colors pt-32 pb-20">
<!-- Background Effects -->
<div class="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<div class="absolute top-[-20%] right-[-10%] w-[60%] h-[60%] rounded-full bg-blue-600/10 blur-[140px] animate-pulse-slow"/>
<div class="absolute bottom-[-20%] left-[-10%] w-[60%] h-[60%] rounded-full bg-indigo-600/10 blur-[140px] animate-pulse-slow" style="animation-delay: 3s;"/>
</div>
<div class="container mx-auto max-w-6xl px-6">
<!-- Back Button -->
<NuxtLink to="/browse" class="inline-flex items-center gap-2 text-slate-400 hover:text-white mb-8 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
กลบหนารายการคอร
</NuxtLink>
<div v-if="course" class="grid grid-cols-1 lg:grid-cols-12 gap-10">
<!-- Main Content (Left Column) -->
<div class="lg:col-span-8">
<!-- Hero Video Placeholder / Thumbnail -->
<div class="relative aspect-video bg-slate-800 rounded-3xl overflow-hidden border border-white/5 shadow-2xl mb-8 group cursor-pointer">
<div v-if="course.thumbnail_url" class="absolute inset-0">
<img :src="course.thumbnail_url" class="w-full h-full object-cover opacity-80 group-hover:opacity-60 transition-opacity" />
</div>
<div class="absolute inset-0 bg-gradient-to-br from-slate-800/50 to-slate-900/50 flex items-center justify-center">
<div class="w-20 h-20 rounded-full bg-blue-600/80 backdrop-blur flex items-center justify-center shadow-[0_0_30px_rgba(37,99,235,0.5)] group-hover:scale-110 transition-transform duration-300">
<div class="ml-1 w-0 h-0 border-t-[12px] border-t-transparent border-l-[20px] border-l-white border-b-[12px] border-b-transparent"/>
</div>
</div>
</div>
<h1 class="text-4xl font-black text-white mb-6 leading-tight">{{ getLocalizedText(course.title) }}</h1>
<p class="text-slate-400 text-lg leading-relaxed mb-10">
{{ getLocalizedText(course.description) }}
</p>
<!-- Learning Objectives (Placeholder if not in API) -->
<!--
<div class="p-8 rounded-3xl bg-white/5 border border-white/5 mb-10">
<h3 class="font-bold text-xl text-white mb-6">งทณจะไดเรยนร</h3>
<ul class="grid grid-cols-1 md:grid-cols-2 gap-4">
...
</ul>
</div>
-->
<!-- Course Syllabus (Placeholder if not in API) -->
<div class="p-8 rounded-3xl bg-white/5 border border-white/5">
<h3 class="font-bold text-xl text-white mb-6">เนอหาในคอร</h3>
<p class="text-slate-500">
{{ course.lessons ? `${course.lessons} บทเรียน` : 'ยังไม่มีเนื้อหาแสดง' }}
</p>
</div>
</div>
<!-- Sidebar (Right Column): Sticky CTA -->
<div class="lg:col-span-4">
<div class="sticky top-28 p-8 rounded-3xl bg-slate-800/80 backdrop-blur-xl border border-white/10 shadow-2xl">
<div class="mb-8 text-center lg:text-left">
<span class="text-lg text-slate-500 line-through mr-4" v-if="course.original_price">{{ course.original_price }}</span>
<span class="text-5xl font-black text-white tracking-tight">{{ course.is_free ? 'ฟรี' : course.price }}</span>
</div>
<NuxtLink to="/auth/register" class="flex items-center justify-center w-full py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold text-lg shadow-lg shadow-blue-600/30 transition-all hover:-translate-y-1 mb-6">
ลงทะเบยนเรยนทนท
</NuxtLink>
<div class="space-y-4 text-sm text-slate-400">
<div class="flex justify-between py-3 border-b border-white/5">
<span>จำนวนบทเรยน</span>
<span class="font-bold text-white">{{ course.lessons || 0 }} บท</span>
</div>
<div class="flex justify-between py-3 border-b border-white/5">
<span>ใบประกาศนยบตร</span>
<span class="font-bold text-white">{{ course.have_certificate ? 'มี' : 'ไม่มี' }}</span>
</div>
<div class="flex justify-between py-3 border-b border-white/5">
<span>ระด</span>
<span class="font-bold text-white">งหมด</span>
</div>
</div>
</div>
</div>
</div>
<!-- Loading / Error State -->
<div v-else class="text-center py-20 text-slate-400">
<p v-if="error">เกิดข้อผิดพลาดในการโหลดข้อมูล</p>
<p v-else>กำลังโหลด...</p>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes pulse-slow {
0%, 100% { opacity: 0.1; transform: scale(1); }
50% { opacity: 0.15; transform: scale(1.15); }
}
.animate-pulse-slow {
animation: pulse-slow 10s linear infinite;
}
</style>