feat: Implement initial e-learning platform frontend structure including dashboard, course management, authentication, and common UI components.

This commit is contained in:
supalerk-ar66 2026-02-27 10:05:33 +07:00
parent aceeb80d9a
commit ad11c6b7c5
44 changed files with 720 additions and 578 deletions

View file

@ -1,18 +1,21 @@
// Interface สำหรับข้อมูลหมวดหมู่ (Category)
/**
* @interface Category
* @description (Category)
*/
export interface Category {
id: number
name: {
name: { // ชื่อหมวดหมู่รองรับ 2 ภาษา
th: string
en: string
[key: string]: string
}
slug: string
description: {
slug: string // Slug สำหรับใช้งานบน URL
description: { // รายละเอียดหมวดหมู่
th: string
en: string
[key: string]: string
}
icon: string
icon: string // ไอคอนของหมวดหมู่อ้างอิงจาก Material Icons
sort_order: number
is_active: boolean
created_at: string
@ -20,7 +23,7 @@ export interface Category {
}
export interface CategoryData {
total: number
total: number // จำนวนหมวดหมู่ทั้งหมด
categories: Category[]
}
@ -30,18 +33,24 @@ interface CategoryResponse {
data: CategoryData
}
// Composable สำหรับจัดการข้อมูลหมวดหมู่
/**
* @composable useCategory
* @description (Categories) Cache ()
*/
export const useCategory = () => {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBase as string
const { token } = useAuth()
// ใช้ useState เพื่อเก็บ Cached Data ระดับ Global (แชร์กันทุก Component)
// เก็บ Cache การดึงข้อมูลหมวดหมู่ในระดับ Global (ใช้ข้าม Component ได้โดยไม่ต้องโหลดใหม่)
const categoriesState = useState<Category[]>('categories_cache', () => [])
const isLoaded = useState<boolean>('categories_loaded', () => false)
// ฟังก์ชันดึงข้อมูลหมวดหมู่ทั้งหมด
// Endpoint: GET /categories
/**
* @function fetchCategories
* @description API (GET /categories)
* State (forceRefresh)
*/
const fetchCategories = async (forceRefresh = false) => {
// ถ้ามีข้อมูลอยู่แล้วและไม่สั่งบังคับโหลดใหม่ ให้ใช้ข้อมูลเดิมทันที
if (isLoaded.value && !forceRefresh && categoriesState.value.length > 0) {
@ -62,7 +71,7 @@ export const useCategory = () => {
const categories = response.data?.categories || []
// เก็บข้อมูลลง State
// บันทึกรายการหมวดหมู่ลง State Cache
categoriesState.value = categories
isLoaded.value = true
@ -74,9 +83,9 @@ export const useCategory = () => {
} catch (err: any) {
console.error('Fetch categories failed:', err)
// Retry logic for 429 Too Many Requests
// กรณีเกิด Error 429 ระบบจะทำการหน่วงเวลา (1.5 วิ) และลองโหลดข้อมูลใหม่อีก 1 ครั้ง (Retry)
if (err.statusCode === 429 || err.status === 429) {
await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5s
await new Promise(resolve => setTimeout(resolve, 1500)); // หน่วงเวลา 1.5 วินาที
try {
const retryRes = await $fetch<CategoryResponse>(`${API_BASE_URL}/categories`, {
method: 'GET',

View file

@ -1,3 +1,7 @@
/**
* @interface ValidationRule
* @description ( , , )
*/
export interface ValidationRule {
required?: boolean
minLength?: number
@ -8,10 +12,18 @@ export interface ValidationRule {
custom?: (value: string) => string | null
}
/**
* @interface FieldErrors
* @description (Key )
*/
export interface FieldErrors {
[key: string]: string
}
/**
* @composable useFormValidation
* @description (Validate)
*/
export function useFormValidation() {
const errors = ref<FieldErrors>({})
@ -52,6 +64,7 @@ export function useFormValidation() {
return null
}
// ฟังก์ชันหลักที่เอาแบบฟอร์ม (formData) มาตรวจกับเช็คลิสต์ทั้งหมด (validationRules)
const validate = (
formData: Record<string, string>,
validationRules: Record<string, { rules: ValidationRule; label: string; messages?: Record<string, string> }>
@ -67,14 +80,18 @@ export function useFormValidation() {
}
}
// บันทึกข้อผิดพลาดที่พบทั้งหมดลงใน State
errors.value = newErrors
// คืนค่าบอกว่า "ฟอร์มนี้ผ่านทั้งหมดไหม" (true คือผ่านหมด)
return isValid
}
// ฟังก์ชันลบข้อผิดพลาดทิ้งทั้งหมด (สำหรับตอนเริ่มกรอกใหม่)
const clearErrors = () => {
errors.value = {}
}
// ฟังก์ชันลบข้อผิดพลาดเฉพาะฟิลด์ที่กำหนด
const clearFieldError = (field: string) => {
if (errors.value[field]) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete

View file

@ -1,22 +1,27 @@
/**
* @composable useMediaPrefs
* @description (Mute) /
* <video>
*/
export const useMediaPrefs = () => {
// 1. Global State
// ใช้ useState เพื่อแชร์ค่าเดียวกันทั่วทั้ง App (เช่น เปลี่ยนหน้าแล้วเสียงยังเท่าเดิม)
// 1. สถานะส่วนกลาง (Global State)
// ใช้ useState เพื่อแชร์ค่าเดียวกันทั่วหน้าเว็บ (เช่น เปลี่ยนหน้าแล้วระดับเสียงยังคงที่)
const volume = useState<number>('media_prefs_volume', () => 1)
const muted = useState<boolean>('media_prefs_muted', () => false)
const { user } = useAuth()
// 2. Storage Key Helper (User Specific)
// 2. ฟังก์ชันช่วยสร้าง Key สำหรับ Storage (เก็บแยกตาม User)
const getStorageKey = () => {
const userId = user.value?.id || 'guest'
return `media:prefs:v1:${userId}`
}
// 3. Save Logic (Throttled)
// 3. ระบบบันทึกการตั้งค่าลงเบราว์เซอร์ (Throttled เพื่อไม่ให้บันทึกถี่เกินไป)
let saveTimeout: ReturnType<typeof setTimeout> | null = null
const save = () => {
if (import.meta.server) return
if (import.meta.server) return // เลี่ยงไม่ได้ต้องทำงานบนฝั่ง Client เท่านั้น
if (saveTimeout) clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
@ -29,12 +34,12 @@ export const useMediaPrefs = () => {
}
localStorage.setItem(key, JSON.stringify(data))
} catch (e) {
console.error('Failed to save media prefs', e)
console.error('ไม่สามารถบันทึกการตั้งค่าสื่อได้', e)
}
}, 500) // Throttle 500ms
}, 500) // หน่วงเวลา 500ms
}
// 4. Load Logic
// 4. ระบบโหลดการตั้งค่าเก่าขึ้นมา (Load Logic)
const load = () => {
if (import.meta.server) return
@ -51,20 +56,20 @@ export const useMediaPrefs = () => {
}
}
} catch (e) {
console.error('Failed to load media prefs', e)
console.error('ไม่สามารถโหลดการตั้งค่าสื่อได้', e)
}
}
// 5. Setters (With Logic)
// 5. ฟังก์ชันสำหรับอัปเดตและสั่งบันทึกการตั้งค่า (Setters)
const setVolume = (val: number) => {
const clamped = Math.max(0, Math.min(1, val))
volume.value = clamped
// Auto unmute if volume increased from 0
// ยกเลิกปิดเสียงอัตโนมัติ ถ้าระดับเสียงเพิ่มขึ้นจาก 0
if (clamped > 0 && muted.value) {
muted.value = false
}
// Auto mute if volume set to 0
// ปิดเสียงอัตโนมัติ ถ้าระดับเสียงกลายเป็น 0
if (clamped === 0 && !muted.value) {
muted.value = true
}
@ -75,7 +80,7 @@ export const useMediaPrefs = () => {
const setMuted = (val: boolean) => {
muted.value = val
// Logic: Unmuting should restore volume if it was 0
// หากผู้ใช้กดยกเลิกการปิดเสียงขณะที่ระดับเสียงเคยเป็น 0 ควรตั้งค่าเริ่มต้นให้เป็น 1
if (!val && volume.value === 0) {
volume.value = 1
}
@ -83,15 +88,15 @@ export const useMediaPrefs = () => {
save()
}
// 6. Apply & Bind to Element (The Magic)
// 6. ฟังก์ชันจับคู่ใช้กับการเล่นสื่อ (ตย. <video ref="videoEl"> -> applyTo(videoEl.value))
const applyTo = (el: HTMLMediaElement | null | undefined) => {
if (!el) return () => {}
// Initial Apply
// ใส่ค่าตั้งต้นให้กับออบเจ็กต์สื่อ
el.volume = volume.value
el.muted = muted.value
// A. Watch State -> Update Element
// A. สังเกตการเปลี่ยนแปลงจาก State -> เพื่อส่งไปอัปเดต Element สื่อ
const stopVolWatch = watch(volume, (v) => {
if (Math.abs(el.volume - v) > 0.01) el.volume = v
})
@ -99,9 +104,9 @@ export const useMediaPrefs = () => {
if (el.muted !== m) el.muted = m
})
// B. Listen Element -> Update State (e.g. Native Controls)
// B. สังเกตการเปลี่ยนแปลงจาก Element (เช่น ผู้ใช้กดปุ่มเร่งเสียงในวิดีโอตรงๆ) -> เพื่อเอาค่ามาอัปเดต State
const onVolumeChange = () => {
// Update state only if diff allows (prevent loop)
// อัปเดตเฉพาะเมื่อมีความแตกต่างเพื่อหลีกเลี่ยง Loop อนันต์
if (Math.abs(el.volume - volume.value) > 0.01) {
volume.value = el.volume
save()
@ -113,7 +118,7 @@ export const useMediaPrefs = () => {
}
el.addEventListener('volumechange', onVolumeChange)
// Cleanup function
// ฟังก์ชันล้างค่าเพื่อเลิกติดตาม (Cleanup แบบส่งกลับ (Return))
return () => {
stopVolWatch()
stopMutedWatch()
@ -121,11 +126,11 @@ export const useMediaPrefs = () => {
}
}
// 7. Lifecycle & Sync
// 7. จังหวะวงจรชีวิตตอนโหลดเสร็จและระบบ Sync
if (import.meta.client) {
onMounted(() => {
load()
// Cross-tab sync
// ระบบ Sync กับแท็บหรือหน้าต่างเดียวกันหากถูกเปิดไว้
window.addEventListener('storage', (e) => {
if (e.key === getStorageKey()) {
load()

View file

@ -1,17 +1,19 @@
/**
* @file useNavItems.ts
* @description Single Source of Truth for navigation items used across the app (Sidebar, Mobile Nav, User Menu).
* @description (Navigation Items)
* ( , , )
*/
export interface NavItem {
to: string
labelKey: string
icon: string
showOn: ('sidebar' | 'mobile' | 'userMenu')[]
roles?: string[]
to: string // ลิงก์ปลายทาง
labelKey: string // คีย์ภาษาสำหรับ i18n
icon: string // ไอคอนจาก Material Icons
showOn: ('sidebar' | 'mobile' | 'userMenu')[] // กำหนดให้โชว์ที่ส่วนไหนบ้าง
roles?: string[] // กำหนดสิทธิ์ผู้ใช้ที่จะเห็น (ถ้ามี)
}
export const useNavItems = () => {
// เมนูทั้งหมดในระบบ กำหนดไว้ที่เดียว
const allNavItems: NavItem[] = [
{
to: '/dashboard',
@ -63,6 +65,7 @@ export const useNavItems = () => {
}
]
// คัดกรองเมนูที่จะเอาไปแสดงแต่ละตำแหน่ง
const sidebarItems = computed(() => allNavItems.filter(item => item.showOn.includes('sidebar')))
const mobileItems = computed(() => allNavItems.filter(item => item.showOn.includes('mobile')))
const userMenuItems = computed(() => allNavItems.filter(item => item.showOn.includes('userMenu')))

View file

@ -19,95 +19,107 @@ export interface AnswerState {
/**
* @composable useQuizRunner
* @description Manages the state and logic for running a quiz activity.
* @description State () Logic (Quiz)
* , ,
*/
export const useQuizRunner = () => {
// State
const questions = useState<QuizQuestion[]>('quiz-questions', () => []);
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({}));
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0);
const loading = useState<boolean>('quiz-loading', () => false);
const lastError = useState<string | null>('quiz-error', () => null);
// ================= State (สถานะเก็บค่าต่างๆ ของข้อสอบ) =================
const questions = useState<QuizQuestion[]>('quiz-questions', () => []); // เก็บรายการคำถามทั้งหมด
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({})); // เก็บคำตอบที่ผู้ใช้ตอบ แยกตาม ID คำถาม
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0); // ลำดับคำถามที่กำลังทำอยู่ปัจจุบัน (เริ่มที่ 0)
const loading = useState<boolean>('quiz-loading', () => false); // สถานะตอนกำลังกดเซฟหรือโหลดข้อมูล
const lastError = useState<string | null>('quiz-error', () => null); // เก็บข้อความแจ้งเตือนข้อผิดพลาดล่าสุด
// Getters
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]);
// ================= Getters (ดึงค่าที่ถูกประมวลผลแล้ว) =================
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]); // ดึงคำถามข้อปัจจุบัน
const currentAnswer = computed(() => {
const currentAnswer = computed(() => { // ดึงคำตอบในข้อปัจจุบัน
if (!currentQuestion.value) return null;
return answers.value[currentQuestion.value.id];
});
const totalQuestions = computed(() => questions.value.length);
const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1);
const isFirstQuestion = computed(() => currentQuestionIndex.value === 0);
const totalQuestions = computed(() => questions.value.length); // จำนวนคำถามทั้งหมดในแบบทดสอบ
const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1); // เช็คว่าใช่คำถามข้อสุดท้ายหรือไม่
const isFirstQuestion = computed(() => currentQuestionIndex.value === 0); // เช็คว่าใช่คำถามข้อแรกหรือไม่
// Actions
// ================= Actions (ฟังก์ชันหลักสำหรับการทำงาน) =================
// ฟังก์ชันเริ่มต้นสร้าง/โหลดข้อสอบ (กำหนดโครงสร้างพื้นฐาน)
function initQuiz(quizData: any) {
if (!quizData || !quizData.questions) return;
questions.value = quizData.questions;
currentQuestionIndex.value = 0;
currentQuestionIndex.value = 0; // รีเซ็ตไปที่ข้อ 1 ใหม่
answers.value = {};
lastError.value = null;
// เตรียมโครงสร้างคำตอบรองรับทุกข้อ
questions.value.forEach(q => {
answers.value[q.id] = {
questionId: q.id,
value: null,
is_saved: false,
status: 'not_started',
touched: false,
is_saved: false, // บันทึกและส่ง API เรียบร้อยหรือยัง
status: 'not_started', // สถานะเริ่มต้นของคำถาม
touched: false, // ผู้ใช้เคยเปิดเข้ามาดูข้อนีัหรือยัง
};
});
// เริ่มต้นบันทึกเวลา/เข้าสู่ข้อที่ 1 ทันทีเมื่ออธิบายเสร็จ
if (questions.value.length > 0) {
enterQuestion(questions.value[0].id);
}
}
// ฟังก์ชันสลับสถานะเมื่อกดเข้ามาที่คำถามนั้นๆ
function enterQuestion(qId: number) {
const ans = answers.value[qId];
if (ans) {
ans.touched = true;
if (ans.status === 'not_started' || ans.status === 'skipped') {
ans.status = 'in_progress';
ans.status = 'in_progress'; // เปลี่ยนสถานะเป็น 'กำลังทำ'
}
}
}
// ตรวจสอบเงื่อนไขว่าผู้ใช้สามารถออกจากคำถามปัจจุบันไปยังข้ออื่นได้หรือไม่
function canLeaveCurrent(): { allowed: boolean; reason?: string } {
if (!currentQuestion.value) return { allowed: true };
const q = currentQuestion.value;
const a = answers.value[q.id];
// สามารถออกได้ถ้าคำถามทำถูกหรือโจทย์อนุญาตให้ข้ามได้
if (a.status === 'completed' || a.is_saved) return { allowed: true };
if (q.is_skippable) return { allowed: true };
// บังคับให้ตอบถ้าไม่ได้อนุญาตให้ข้าม และไม่ได้ตอบ
if (!a.is_saved && a.value === null) {
return { allowed: false, reason: 'This question is required.' };
return { allowed: false, reason: 'ต้องการคำตอบสำหรับข้อบังคับนี้' };
}
return { allowed: true };
}
// ฟังก์ชันอัปเดตค่าตัวเลือกที่ผู้ใช้กดเลือกในข้อปัจจุบัน
function updateAnswer(val: any) {
if (!currentQuestion.value) return;
const qId = currentQuestion.value.id;
answers.value[qId].value = val;
// หากมีแก้ไขคำตอบหลังจากกดเซฟไปแล้ว ให้เปลี่ยนสถานะให้ระบบรู้ว่าต้องเซฟใหม่
if (answers.value[qId].is_saved) {
answers.value[qId].is_saved = false;
answers.value[qId].status = 'in_progress';
answers.value[qId].status = 'in_progress';
}
}
// ล็อกและบันทึกข้อสอบเมื่อกดปุ่ม "ตกลง/ส่งคำตอบ" สำหรับข้อนั้นๆ
async function saveCurrentAnswer() {
if (!currentQuestion.value) return;
const qId = currentQuestion.value.id;
const ans = answers.value[qId];
if (ans.value === null) {
lastError.value = "Please provide an answer.";
lastError.value = "กรุณาเลือกคำตอบอย่างน้อย 1 ตัวเลือก";
return false;
}
@ -115,30 +127,34 @@ export const useQuizRunner = () => {
lastError.value = null;
try {
// หมายเหตุ: การเชื่อมต่อ API หลักต้องทำที่ไฟล์ component, ตัวนี้จัดการแค่เรื่อง State
ans.is_saved = true;
ans.status = 'completed';
ans.status = 'completed'; // มาร์คว่าเป็นข้อที่ทำเสร็จแล้ว
ans.last_saved_at = new Date().toISOString();
return true;
} catch (e) {
lastError.value = "Failed to save answer.";
lastError.value = "เกิดข้อผิดพลาดในการบันทึกคำตอบ";
return false;
} finally {
loading.value = false;
}
}
// วินิจฉัยก่อนสั่งผู้ใช้ย้ายไปยังคำถามอื่นหน้าอื่นตามดัชนีระบุ
function handleLeaveLogic(targetIndex: number) {
if (targetIndex === currentQuestionIndex.value) return;
// ตรวจสอบขั้นสุดท้าย ป้องกันคนคลิกแอบหนีข้อที่บังคับทำ
const check = canLeaveCurrent();
if (!check.allowed) {
lastError.value = check.reason || "Required question.";
lastError.value = check.reason || "จำเป็นต้องตอบข้อนี้ก่อนข้าม";
return false;
}
const currQ = currentQuestion.value;
if (currQ) {
const currAns = answers.value[currQ.id];
// หากผู้ใช้ทิ้งขว้างโดยที่ไม่บังคับ ให้ทิ้งสถานะเป็นข้าม ('skipped')
if (currAns.status !== 'completed' && !currAns.is_saved) {
currAns.status = 'skipped';
}
@ -147,6 +163,7 @@ export const useQuizRunner = () => {
currentQuestionIndex.value = targetIndex;
lastError.value = null;
// ติดตามสถานะ 'touched' ในข้อใหม่ที่เข้าไปล่าสุด
if (questions.value[targetIndex]) {
enterQuestion(questions.value[targetIndex].id);
}

View file

@ -1,26 +1,41 @@
import { useQuasar } from 'quasar'
/**
* @composable useThemeMode
* @description / (Light/Dark Theme)
* , Tailwind Sync Quasar UI
*/
export const useThemeMode = () => {
const $q = useQuasar()
// deterministic on SSR: default = light
// สถานะเริ่มต้นของโหมดมืด (สำหรับการทำ SSR ถูกเซ็ตเป็น false (สว่าง) ไว้ก่อน)
const isDark = useState<boolean>('theme:isDark', () => false)
// ฟังก์ชันใช้คลาสกับ Tag <html> เพื่อให้ Tailwind หรือ CSS รันโหมดมืด
const applyTheme = (value: boolean) => {
if (!process.client) return
if (!process.client) return // หากทำงานฝั่งเซิร์ฟเวอร์ จะไม่สั่งให้รัน DOM
// สลับคลาส 'dark' หรือปิด
document.documentElement.classList.toggle('dark', value)
// สั่งให้ Quasar (UI Framework) ปรับโหมดสีให้ตรงกัน (มืด/สว่าง)
$q.dark.set(value)
// บันทึกการตั้งค่าลงเครื่องระยะยาวเบราว์เซอร์
localStorage.setItem('theme', value ? 'dark' : 'light')
}
// จับตาดูเมื่อตัวแปรเปลี่ยนค่าค่อยทำการเปลี่ยนโหมดบนหน้าจอ
watch(isDark, (v) => applyTheme(v))
// ฟังก์ชันสั่งสลับโหมด ไปมา (Toggle)
const toggle = () => {
isDark.value = !isDark.value
}
// ฟังก์ชันสำหรับกำหนดตั้งค่าโหมดแบบเจาะจง
const set = (v: boolean) => {
isDark.value = v
}