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"
>
รายละเอยด