feat: Introduce core e-learning features with new pages for course details, dashboard, authentication, browsing, and learning, supported by a useCourse composable.

This commit is contained in:
supalerk-ar66 2026-01-23 09:47:32 +07:00
parent c982ab2c05
commit 0eb9b522f6
6 changed files with 109 additions and 38 deletions

View file

@ -62,11 +62,20 @@ interface EnrolledCourseResponse {
limit: number limit: number
} }
// ==========================================
// Composable: useCourse
// หน้าที่: จัดการ Logic ทุกอย่างเกี่ยวกับคอร์สเรียน
// - ดึงข้อมูลคอร์ส (Public & Protected)
// - ลงทะเบียนเรียน (Enroll)
// - ติดตามความคืบหน้าการเรียน (Progress tracking)
// ==========================================
export const useCourse = () => { export const useCourse = () => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBase as string const API_BASE_URL = config.public.apiBase as string
const { token } = useAuth() const { token } = useAuth()
// ฟังก์ชันดึงรายชื่อคอร์สทั้งหมด (Catalog)
// Endpoint: GET /courses
const fetchCourses = async () => { const fetchCourses = async () => {
try { try {
const data = await $fetch<CourseResponse>(`${API_BASE_URL}/courses`, { const data = await $fetch<CourseResponse>(`${API_BASE_URL}/courses`, {
@ -90,6 +99,8 @@ export const useCourse = () => {
} }
} }
// ฟังก์ชันดึงรายละเอียดคอร์สตาม ID
// Endpoint: GET /courses/:id
const fetchCourseById = async (id: number) => { const fetchCourseById = async (id: number) => {
try { try {
const data = await $fetch<CourseResponse>(`${API_BASE_URL}/courses/${id}`, { const data = await $fetch<CourseResponse>(`${API_BASE_URL}/courses/${id}`, {
@ -130,6 +141,8 @@ export const useCourse = () => {
} }
} }
// ฟังก์ชันลงทะเบียนเรียน
// Endpoint: POST /students/courses/:id/enroll
const enrollCourse = async (courseId: number) => { const enrollCourse = async (courseId: number) => {
try { try {
const data = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/courses/${courseId}/enroll`, { const data = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/courses/${courseId}/enroll`, {
@ -197,6 +210,8 @@ export const useCourse = () => {
} }
} }
// ฟังก์ชันดึงข้อมูลสำหรับการเรียน (Chapters, Lessons, Progress)
// Endpoint: GET /students/courses/:id/learn
const fetchCourseLearningInfo = async (courseId: number) => { const fetchCourseLearningInfo = async (courseId: number) => {
try { try {
const data = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/courses/${courseId}/learn`, { const data = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/courses/${courseId}/learn`, {
@ -220,6 +235,8 @@ export const useCourse = () => {
} }
} }
// ฟังก์ชันดึงเนื้อหาบทเรียน (Video, Content)
// Endpoint: GET /students/courses/:cid/lessons/:lid
const fetchLessonContent = async (courseId: number, lessonId: number) => { const fetchLessonContent = async (courseId: number, lessonId: number) => {
try { try {
const data = await $fetch<{ code: number; message: string; data: any; progress?: any }>(`${API_BASE_URL}/students/courses/${courseId}/lessons/${lessonId}`, { const data = await $fetch<{ code: number; message: string; data: any; progress?: any }>(`${API_BASE_URL}/students/courses/${courseId}/lessons/${lessonId}`, {
@ -269,6 +286,8 @@ export const useCourse = () => {
} }
} }
// ฟังก์ชันบันทึกเวลาที่ดูวิดีโอ (Video Progress)
// Endpoint: POST /students/lessons/:id/progress
const saveVideoProgress = async (lessonId: number, progressSeconds: number, durationSeconds: number) => { const saveVideoProgress = async (lessonId: number, progressSeconds: number, durationSeconds: number) => {
try { try {
const data = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/lessons/${lessonId}/progress`, { const data = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/lessons/${lessonId}/progress`, {
@ -320,6 +339,8 @@ export const useCourse = () => {
} }
} }
// ฟังก์ชันบันทึกว่าเรียนจบบทเรียนแล้ว (Mark Complete)
// Endpoint: POST /students/courses/:cid/lessons/:lid/complete
const markLessonComplete = async (courseId: number, lessonId: number) => { const markLessonComplete = async (courseId: number, lessonId: number) => {
try { try {
const data = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/courses/${courseId}/lessons/${lessonId}/complete`, { const data = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/courses/${courseId}/lessons/${lessonId}/complete`, {

View file

@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file login.vue * @file login.vue
* @description Login Page. * @description หนาเขาสระบบ (Login Page)
* Handles user authentication with email/password and social login (mock). * รองรบการเขาสระบบดวย Email/Password
* Uses the 'auth' layout.
*/ */
definePageMeta({ definePageMeta({
@ -28,6 +27,7 @@ const loginForm = reactive({
}) })
// Validation rules definition // Validation rules definition
// (Validation Rules)
const loginRules = { const loginRules = {
email: { email: {
rules: { rules: {
@ -56,11 +56,12 @@ const loginRules = {
} }
/** /**
* Validates form and attempts login. * งกนตรวจสอบความถกตองของฟอรมและเรยก API login
* Currently simulates an API call for demonstration.
*/ */
/** /**
* Handles input updates, stripping Thai characters and clearing errors. * ดการเมอมการพมพอม (Input Handler)
* - ลบ error เมอเรมพมพใหม
* - ตรวจสอบภาษาไทยแบบ real-time
*/ */
const handleInput = (field: keyof typeof loginForm, value: string) => { const handleInput = (field: keyof typeof loginForm, value: string) => {
loginForm[field] = value loginForm[field] = value

View file

@ -15,23 +15,36 @@ useHead({
title: "รายการคอร์ส - e-Learning", title: "รายการคอร์ส - e-Learning",
}); });
// UI State // ==========================================
// 1. State ( UI)
// ==========================================
// showDetail: (true = , false = )
const showDetail = ref(false); const showDetail = ref(false);
// searchQuery:
const searchQuery = ref(""); const searchQuery = ref("");
// isCategoryOpen: /
const isCategoryOpen = ref(true); const isCategoryOpen = ref(true);
// Helper to get localized text // ==========================================
// 2. (Helpers)
// ==========================================
// getLocalizedText:
// object {th, en} th , en
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => { const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
return text.th || text.en || '' return text.th || text.en || ''
} }
// Categories Data // ==========================================
// 3. (Categories)
// ==========================================
// useCategory composable API
const { fetchCategories } = useCategory(); const { fetchCategories } = useCategory();
const categories = ref<any[]>([]); const categories = ref<any[]>([]);
const showAllCategories = ref(false); const showAllCategories = ref(false); // (Show More/Less)
//
const loadCategories = async () => { const loadCategories = async () => {
const res = await fetchCategories(); const res = await fetchCategories();
if (res.success) { if (res.success) {
@ -39,22 +52,28 @@ const loadCategories = async () => {
} }
}; };
// ( Show More , 8 )
const visibleCategories = computed(() => { const visibleCategories = computed(() => {
return showAllCategories.value ? categories.value : categories.value.slice(0, 8); return showAllCategories.value ? categories.value : categories.value.slice(0, 8);
}); });
// Courses Data // ==========================================
// 4. (Courses)
// ==========================================
// useCourse composable (, )
const { fetchCourses, fetchCourseById, enrollCourse } = useCourse(); const { fetchCourses, fetchCourseById, enrollCourse } = useCourse();
const courses = ref<any[]>([]); const courses = ref<any[]>([]);
const isLoading = ref(false); const isLoading = ref(false); //
const selectedCourse = ref<any>(null); const selectedCourse = ref<any>(null); //
const isLoadingDetail = ref(false); const isLoadingDetail = ref(false); //
const isEnrolling = ref(false); const isEnrolling = ref(false); //
//
const loadCourses = async () => { const loadCourses = async () => {
isLoading.value = true; isLoading.value = true;
const res = await fetchCourses(); const res = await fetchCourses();
if (res.success) { if (res.success) {
//
courses.value = (res.data || []).map((c: any) => ({ courses.value = (res.data || []).map((c: any) => ({
...c, ...c,
rating: "0.0", rating: "0.0",
@ -65,11 +84,13 @@ const loadCourses = async () => {
isLoading.value = false; isLoading.value = false;
}; };
//
const selectCourse = async (id: number) => { const selectCourse = async (id: number) => {
isLoadingDetail.value = true; isLoadingDetail.value = true;
selectedCourse.value = null; selectedCourse.value = null;
showDetail.value = true; showDetail.value = true; // Detail View
// API ID
const res = await fetchCourseById(id); const res = await fetchCourseById(id);
if (res.success) { if (res.success) {
selectedCourse.value = res.data; selectedCourse.value = res.data;
@ -77,14 +98,15 @@ const selectCourse = async (id: number) => {
isLoadingDetail.value = false; isLoadingDetail.value = false;
}; };
// (Enroll)
const handleEnroll = async (id: number) => { const handleEnroll = async (id: number) => {
if (isEnrolling.value) return; if (isEnrolling.value) return; //
isEnrolling.value = true; isEnrolling.value = true;
const res = await enrollCourse(id); const res = await enrollCourse(id);
if (res.success) { if (res.success) {
// Navigate to my-courses where the success modal will be shown // "" parameter enrolled=true popup
return navigateTo('/dashboard/my-courses?enrolled=true'); return navigateTo('/dashboard/my-courses?enrolled=true');
} else { } else {
alert(res.error || 'Failed to enroll'); alert(res.error || 'Failed to enroll');
@ -94,23 +116,28 @@ const handleEnroll = async (id: number) => {
}; };
onMounted(() => { onMounted(() => {
//
loadCategories(); loadCategories();
loadCourses(); loadCourses();
}); });
// Filter Logic based on search query // Filter Logic based on search query
// Filter Logic based on search query and category // ==========================================
// 5. (Filter & Search)
// ==========================================
// selectedCategoryIds: ID
const selectedCategoryIds = ref<number[]>([]); const selectedCategoryIds = ref<number[]>([]);
// (Filter Logic)
const filteredCourses = computed(() => { const filteredCourses = computed(() => {
let result = courses.value; let result = courses.value;
// Filter by Category // 1. (Category Filter)
if (selectedCategoryIds.value.length > 0) { if (selectedCategoryIds.value.length > 0) {
result = result.filter(c => selectedCategoryIds.value.includes(c.category_id)); result = result.filter(c => selectedCategoryIds.value.includes(c.category_id));
} }
// Filter by Search Query // 2. (Search Query) -
if (searchQuery.value) { if (searchQuery.value) {
const query = searchQuery.value.toLowerCase(); const query = searchQuery.value.toLowerCase();
result = result.filter( result = result.filter(

View file

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file learning.vue * @file learning.vue
* @description Course Learning Interface ("Classroom" view). * @description หนาเรยนออนไลน (Classroom Interface)
* Defines the main learning environment where users watch video lessons and track progress. * ดการแสดงผลวโอรายการบทเรยน และตดตามความคบหน
* Layout mimics a typical LMS with a sidebar for curriculum and a main content area for video/details. * ออกแบบใหเหมอนระบบ LMS มาตรฐาน
* @important Matches the provided design mockups pixel-perfectly.
*/ */
definePageMeta({ definePageMeta({
layout: false, // Custom layout defined within this component layout: false, // Custom layout defined within this component
middleware: 'auth' middleware: 'auth'
@ -24,18 +24,23 @@ const sidebarOpen = ref(false)
const activeTab = ref<'details' | 'announcements'>('details') const activeTab = ref<'details' | 'announcements'>('details')
const courseId = computed(() => Number(route.query.course_id)) const courseId = computed(() => Number(route.query.course_id))
// ==========================================
// 1. State ( UI)
// ==========================================
// courseData: ()
const courseData = ref<any>(null) const courseData = ref<any>(null)
// currentLesson:
const currentLesson = ref<any>(null) const currentLesson = ref<any>(null)
const isLoading = ref(true) const isLoading = ref(true) //
const isLessonLoading = ref(false) const isLessonLoading = ref(false) //
// Video Player Logic // Video Player State ()
const videoRef = ref<HTMLVideoElement | null>(null) const videoRef = ref<HTMLVideoElement | null>(null)
const isPlaying = ref(false) const isPlaying = ref(false)
const videoProgress = ref(0) const videoProgress = ref(0)
const currentTime = ref(0) const currentTime = ref(0)
const duration = ref(0) const duration = ref(0)
const saveProgressInterval = ref<any>(null) const saveProgressInterval = ref<any>(null) // setInterval
// Helper for localization // Helper for localization
const getLocalizedText = (text: any) => { const getLocalizedText = (text: any) => {
@ -59,6 +64,11 @@ const switchTab = (tab: 'details' | 'announcements', lessonId: any = null) => {
} }
// Data Fetching // Data Fetching
// ==========================================
// 2. (Data Fetching)
// ==========================================
//
const loadCourseData = async () => { const loadCourseData = async () => {
if (!courseId.value) return if (!courseId.value) return
isLoading.value = true isLoading.value = true
@ -67,7 +77,7 @@ const loadCourseData = async () => {
if (res.success) { if (res.success) {
courseData.value = res.data courseData.value = res.data
// Auto-load first unlocked lesson if no current lesson // Auto-load logic:
if (!currentLesson.value) { if (!currentLesson.value) {
const firstChapter = res.data.chapters[0] const firstChapter = res.data.chapters[0]
if (firstChapter && firstChapter.lessons.length > 0) { if (firstChapter && firstChapter.lessons.length > 0) {
@ -164,7 +174,10 @@ const videoSrc = computed(() => {
return '' return ''
}) })
// Save progress periodically // ==========================================
// 3. (Progress Tracking)
// ==========================================
// 10
watch(() => isPlaying.value, (playing) => { watch(() => isPlaying.value, (playing) => {
if (playing) { if (playing) {
saveProgressInterval.value = setInterval(() => { saveProgressInterval.value = setInterval(() => {
@ -190,11 +203,12 @@ watch(() => isPlaying.value, (playing) => {
} }
}) })
// (Complete)
const onVideoEnded = async () => { const onVideoEnded = async () => {
isPlaying.value = false isPlaying.value = false
if (currentLesson.value) { if (currentLesson.value) {
await markLessonComplete(courseId.value, currentLesson.value.id) await markLessonComplete(courseId.value, currentLesson.value.id)
// Reload course data to update sidebar progress/locks //
await loadCourseData() await loadCourseData()
// Auto play next logic could go here // Auto play next logic could go here

View file

@ -11,9 +11,12 @@ definePageMeta({
}) })
const route = useRoute() const route = useRoute()
// courseId URL params ( integer)
const courseId = computed(() => parseInt(route.params.id as string)) const courseId = computed(() => parseInt(route.params.id as string))
const { fetchCourseById, enrollCourse } = useCourse() const { fetchCourseById, enrollCourse } = useCourse()
// useAsyncData Server-side rendering (SSR)
// Key: 'course-{id}' cache ID
const { data: courseData, error, refresh } = await useAsyncData(`course-${courseId.value}`, () => fetchCourseById(courseId.value)) const { data: courseData, error, refresh } = await useAsyncData(`course-${courseId.value}`, () => fetchCourseById(courseId.value))
const course = computed(() => { const course = computed(() => {
@ -22,18 +25,20 @@ const course = computed(() => {
const isEnrolling = ref(false) const isEnrolling = ref(false)
// ""
const handleEnroll = async () => { const handleEnroll = async () => {
if (!course.value) return if (!course.value) return
if (isEnrolling.value) return if (isEnrolling.value) return
isEnrolling.value = true isEnrolling.value = true
// API
const res = await enrollCourse(course.value.id) const res = await enrollCourse(course.value.id)
if (res.success) { if (res.success) {
// Navigate to my-courses // "" params enrolled=true
return navigateTo('/dashboard/my-courses?enrolled=true') return navigateTo('/dashboard/my-courses?enrolled=true')
} else { } else {
// Handle error (alert for now, could be toast) // error alert ( Toast notification)
alert(res.error || 'Failed to enroll') alert(res.error || 'Failed to enroll')
} }

View file

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file home.vue * @file home.vue
* @description Dashboard / Home Page. * @description หนาแดชบอรดหล (Dashboard)
* Displays the user's dashboard with a welcome message, current learning progress, and course recommendations. * แสดงขอความตอนร และคอรสแนะนำสำหรบผเรยน
*/ */
definePageMeta({ definePageMeta({
@ -26,10 +26,12 @@ const getLocalizedText = (text: string | { th: string; en: string } | undefined)
} }
// Recommended Courses State // Recommended Courses State
// ( 3 )
const recommendedCourses = ref<any[]>([]) const recommendedCourses = ref<any[]>([])
onMounted(async () => { onMounted(async () => {
// 1. Fetch Categories for mapping // 1. Fetch Categories for mapping
//
const catRes = await fetchCategories() const catRes = await fetchCategories()
const catMap = new Map() const catMap = new Map()
if (catRes.success) { if (catRes.success) {
@ -37,12 +39,13 @@ onMounted(async () => {
} }
// 2. Fetch All Courses and Randomize // 2. Fetch All Courses and Randomize
// 3
const res = await fetchCourses() const res = await fetchCourses()
if (res.success && res.data?.length) { if (res.success && res.data?.length) {
// Shuffle array // Shuffle array ()
const shuffled = [...res.data].sort(() => 0.5 - Math.random()) const shuffled = [...res.data].sort(() => 0.5 - Math.random())
// Pick first 3 // Pick first 3 ( 3 )
recommendedCourses.value = shuffled.slice(0, 3).map((c: any) => ({ recommendedCourses.value = shuffled.slice(0, 3).map((c: any) => ({
id: c.id, id: c.id,
title: getLocalizedText(c.title), title: getLocalizedText(c.title),