feat: Implement initial e-learning platform frontend structure including dashboard, course management, authentication, and common UI components.
This commit is contained in:
parent
aceeb80d9a
commit
ad11c6b7c5
44 changed files with 720 additions and 578 deletions
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file AnnouncementModal.vue
|
||||
* @description Modal component to display course announcements
|
||||
* @description คอมโพเนนต์ Modal สำหรับแสดงประกาศของคอร์สเรียน (Modal component to display course announcements)
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -15,7 +15,7 @@ const emit = defineEmits<{
|
|||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
// Helper for localization
|
||||
// ตัวช่วยจัดการข้อความหลายภาษา (Helper for localization)
|
||||
const getLocalizedText = (text: any) => {
|
||||
if (!text) return ''
|
||||
if (typeof text === 'string') return text
|
||||
|
|
@ -49,7 +49,7 @@ const getLocalizedText = (text: any) => {
|
|||
class="p-5 rounded-2xl bg-white dark:bg-slate-800 shadow-sm border border-gray-200 dark:border-white/5 transition-all hover:shadow-md relative overflow-hidden group"
|
||||
:class="{'ring-2 ring-orange-200 dark:ring-orange-900/40 !bg-orange-50/50 dark:!bg-orange-900/20': ann.is_pinned}"
|
||||
>
|
||||
<!-- Pinned Banner -->
|
||||
<!-- ป้ายกำกับสำหรับข้อความที่ปักหมุดไว้ (Pinned Banner) -->
|
||||
<div v-if="ann.is_pinned" class="absolute top-0 right-0 p-3">
|
||||
<q-icon name="push_pin" color="orange" size="18px" class="transform rotate-45" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file CurriculumSidebar.vue
|
||||
* @description Sidebar Component for displaying course curriculum (Chapters & Lessons)
|
||||
* Handles lesson navigation, locked status display, and unread announcement badge.
|
||||
* @description คอมโพเนนต์แถบเมนูด้านข้างสำหรับแสดงหลักสูตรของคอร์สเรียน (บทเรียน & ตอนต่างๆ)
|
||||
* จัดการการนำทางไปยังบทเรียน, แสดงสถานะการล็อค, และแจ้งเตือนประกาศที่ยังไม่ได้อ่าน
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean; // Sidebar open state (v-model)
|
||||
modelValue: boolean; // สถานะการเปิด/ปิดของ Sidebar (Sidebar open state - v-model)
|
||||
courseData: any;
|
||||
currentLessonId?: number;
|
||||
isLoading: boolean;
|
||||
|
|
@ -21,10 +21,10 @@ const emit = defineEmits<{
|
|||
|
||||
const { locale } = useI18n()
|
||||
|
||||
// State for expansion items
|
||||
// สถานะของส่วนที่ขยายได้ (State for expansion items)
|
||||
const chapterOpenState = ref<Record<string, boolean>>({})
|
||||
|
||||
// Helper for localization
|
||||
// ตัวช่วยจัดการข้อความหลายภาษา (Helper for localization)
|
||||
const getLocalizedText = (text: any) => {
|
||||
if (!text) return ''
|
||||
if (typeof text === 'string') return text
|
||||
|
|
@ -34,13 +34,13 @@ const getLocalizedText = (text: any) => {
|
|||
return text[currentLocale] || text.th || text.en || ''
|
||||
}
|
||||
|
||||
// Helper: Check if lesson is completed
|
||||
// ตัวช่วยตรวจสอบว่าบทเรียนเรียนจบหรือยัง (Helper: Check if lesson is completed)
|
||||
const isLessonCompleted = (lesson: any) => {
|
||||
return lesson.is_completed === true || lesson.progress?.is_completed === true
|
||||
}
|
||||
|
||||
// Reactive Chapter Completion Status
|
||||
// Computes a map of chapterId -> boolean (true if all lessons are completed)
|
||||
// ตรวจสอบสถานะการสำเร็จของบท (Reactive Chapter Completion Status)
|
||||
// คำนวณเป็น Map ของ chapterId -> boolean (true ถ้าทุกบทเรียนย่อยเรียนจบแล้ว)
|
||||
const chapterCompletionStatus = computed(() => {
|
||||
const status: Record<string, boolean> = {}
|
||||
if (!props.courseData || !props.courseData.chapters) return status
|
||||
|
|
@ -55,7 +55,7 @@ const chapterCompletionStatus = computed(() => {
|
|||
return status
|
||||
})
|
||||
|
||||
// Local Progress Calculation
|
||||
// คำนวณความคืบหน้าแบบ Local (Local Progress Calculation)
|
||||
const progressPercentage = computed(() => {
|
||||
if (!props.courseData || !props.courseData.chapters) return 0
|
||||
let total = 0
|
||||
|
|
@ -69,7 +69,7 @@ const progressPercentage = computed(() => {
|
|||
return total > 0 ? Math.round((completed / total) * 100) : 0
|
||||
})
|
||||
|
||||
// Auto-expand chapter containing current lesson
|
||||
// ขยายบทเรียนปัจจุบันอัตโนมัติ (Auto-expand chapter containing current lesson)
|
||||
watch(() => props.currentLessonId, (newId) => {
|
||||
if (newId && props.courseData?.chapters) {
|
||||
props.courseData.chapters.forEach((chapter: any) => {
|
||||
|
|
@ -81,7 +81,7 @@ watch(() => props.currentLessonId, (newId) => {
|
|||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Initialize all chapters as open by default on load
|
||||
// ตั้งค่าเริ่มต้นให้ทุกบทเรียนเปิดอยู่เมื่อโหลด (Initialize all chapters as open by default on load)
|
||||
watch(() => props.courseData, (newData) => {
|
||||
if (newData?.chapters) {
|
||||
newData.chapters.forEach((chapter: any) => {
|
||||
|
|
@ -104,10 +104,10 @@ watch(() => props.courseData, (newData) => {
|
|||
:breakpoint="1024"
|
||||
class="bg-slate-50 dark:!bg-slate-900 shadow-xl"
|
||||
>
|
||||
<!-- Main Container: Enforce Column Layout and Full Width -->
|
||||
<!-- คอนเทนเนอร์หลักบังคับใช้ความกว้างเต็มที่ (Main Container: Enforce Column Layout and Full Width) -->
|
||||
<div v-if="courseData" class="flex flex-col w-full h-full overflow-hidden text-slate-900 dark:!text-white relative bg-slate-50 dark:!bg-slate-900">
|
||||
|
||||
<!-- 1. Header Section (Fixed at Top) -->
|
||||
<!-- 1. ส่วนหัว ด้านบนคงที่ (Header Section - Fixed at Top) -->
|
||||
<div class="flex-none p-5 border-b border-slate-200 dark:border-white/10 bg-white dark:!bg-slate-900 z-10 w-full">
|
||||
<h2 class="text-sm font-bold mb-4 line-clamp-2 leading-snug block w-full text-slate-900 dark:!text-white">{{ getLocalizedText(courseData.course.title) }}</h2>
|
||||
|
||||
|
|
@ -123,11 +123,11 @@ watch(() => props.courseData, (newData) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Curriculum List (Scrollable Area) -->
|
||||
<!-- 2. รายการหลักสูตร พื้นที่เลื่อนได้ (Curriculum List - Scrollable Area) -->
|
||||
<div class="flex-1 overflow-y-auto bg-slate-50 dark:!bg-slate-900 w-full p-4 space-y-3">
|
||||
<q-list class="block w-full">
|
||||
<div v-for="(chapter, idx) in courseData.chapters" :key="chapter.id" class="block w-full mb-3">
|
||||
<!-- Chapter Accordion -->
|
||||
<!-- กล่องข้อมูลของบท (Chapter Accordion) -->
|
||||
<q-expansion-item
|
||||
v-model="chapterOpenState[chapter.id]"
|
||||
class="bg-white dark:!bg-slate-800 rounded-xl overflow-hidden shadow-sm border border-slate-200 dark:border-slate-700 w-full"
|
||||
|
|
@ -137,7 +137,7 @@ watch(() => props.courseData, (newData) => {
|
|||
<template v-slot:header>
|
||||
<div class="flex items-center w-full py-3 text-slate-900 dark:!text-white">
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<!-- Chapter Indicator (Check or Number) -->
|
||||
<!-- ตัวบ่งชี้บทเรียน เครื่องหมายถูกหรือตัวเลข (Chapter Indicator - Check or Number) -->
|
||||
<div class="w-7 h-7 rounded-full border-2 flex items-center justify-center transition-colors font-bold"
|
||||
:class="chapterCompletionStatus[chapter.id]
|
||||
? 'border-green-500 text-green-500 bg-green-50 dark:!bg-green-500/10'
|
||||
|
|
@ -146,7 +146,7 @@ watch(() => props.courseData, (newData) => {
|
|||
<span v-else class="text-[10px]">{{ Number(idx) + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Explicitly handle text overflow -->
|
||||
<!-- จัดการตัวอักษรที่ล้นเกินอย่างชัดเจน (Explicitly handle text overflow) -->
|
||||
<div class="flex-1 min-w-0 pr-2 overflow-hidden">
|
||||
<div class="font-bold text-sm leading-tight mb-0.5 truncate block w-full">{{ getLocalizedText(chapter.title) }}</div>
|
||||
<div class="text-[10px] text-slate-500 dark:!text-slate-400 font-normal truncate block w-full">
|
||||
|
|
@ -156,7 +156,7 @@ watch(() => props.courseData, (newData) => {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Lessons List -->
|
||||
<!-- รายการบทเรียนย่อย (Lessons List) -->
|
||||
<div class="bg-slate-50 dark:!bg-slate-800/50 border-t border-slate-100 dark:border-slate-700 w-full">
|
||||
<div
|
||||
v-for="(lesson, lIdx) in chapter.lessons"
|
||||
|
|
@ -167,27 +167,27 @@ watch(() => props.courseData, (newData) => {
|
|||
: 'border-transparent'"
|
||||
@click="!lesson.is_locked && emit('select-lesson', lesson.id)"
|
||||
>
|
||||
<!-- Lesson Status Icon -->
|
||||
<!-- ไอคอนสถานะของบทเรียน (Lesson Status Icon) -->
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<!-- Completed (Takes Precedence) -->
|
||||
<!-- เรียนจบแล้ว (สำคัญที่สุด) (Completed - Takes Precedence) -->
|
||||
<q-icon v-if="isLessonCompleted(lesson)"
|
||||
name="check_circle"
|
||||
class="text-green-500"
|
||||
size="20px"
|
||||
/>
|
||||
<!-- Active/Playing (If not completed) -->
|
||||
<!-- กำลังเรียนอยู่ (Active/Playing - If not completed) -->
|
||||
<q-icon v-else-if="currentLessonId === lesson.id"
|
||||
name="play_circle_filled"
|
||||
class="text-blue-600 dark:!text-blue-400 animate-pulse"
|
||||
size="20px"
|
||||
/>
|
||||
<!-- Locked -->
|
||||
<!-- ถูกล็อคอยู่ (Locked) -->
|
||||
<q-icon v-else-if="lesson.is_locked"
|
||||
name="lock"
|
||||
class="text-slate-400 dark:!text-slate-500 opacity-70"
|
||||
size="18px"
|
||||
/>
|
||||
<!-- Not Started -->
|
||||
<!-- ยังไม่ได้เริ่ม (Not Started) -->
|
||||
<div v-else class="w-[18px] h-[18px] rounded-full border-2 border-slate-300 dark:border-slate-600"></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ watch(() => props.courseData, (newData) => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom scrollbar for better aesthetics */
|
||||
/* สครอลบาร์ปรับแต่งเพื่อความสวยงาม (Custom scrollbar for better aesthetics) */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file VideoPlayer.vue
|
||||
* @description Video Player Component with custom controls provided by design
|
||||
* @description คอมโพเนนต์เครื่องเล่นวิดีโอพร้อมด้วยตัวควบคุมแบบกำหนดเองตามการออกแบบ (Video Player Component with custom controls provided by design)
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -22,7 +22,7 @@ const videoProgress = ref(0);
|
|||
const currentTime = ref(0);
|
||||
const duration = ref(0);
|
||||
|
||||
// Media Prefs
|
||||
// การตั้งค่าเกี่ยวกับสื่อ (Media Prefs)
|
||||
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs();
|
||||
|
||||
const volumeIcon = computed(() => {
|
||||
|
|
@ -40,7 +40,7 @@ const formatTime = (time: number) => {
|
|||
const currentTimeDisplay = computed(() => formatTime(currentTime.value));
|
||||
const durationDisplay = computed(() => formatTime(duration.value || 0));
|
||||
|
||||
// YouTube Helper Logic
|
||||
// ตรรกะตัวช่วยสำหรับ YouTube (YouTube Helper Logic)
|
||||
const isYoutube = computed(() => {
|
||||
const s = props.src.toLowerCase();
|
||||
return s.includes('youtube.com') || s.includes('youtu.be');
|
||||
|
|
@ -50,7 +50,7 @@ const youtubeEmbedUrl = computed(() => {
|
|||
if (!isYoutube.value) return '';
|
||||
let videoId = '';
|
||||
|
||||
// Extract Video ID
|
||||
// ดึงรหัสวิดีโอ (Extract Video ID)
|
||||
if (props.src.includes('youtu.be')) {
|
||||
videoId = props.src.split('youtu.be/')[1]?.split('?')[0];
|
||||
} else {
|
||||
|
|
@ -58,18 +58,18 @@ const youtubeEmbedUrl = computed(() => {
|
|||
videoId = urlParams.get('v') || '';
|
||||
}
|
||||
|
||||
// Return Embed URL with enablejsapi=1
|
||||
// คืนค่า URL สำหรับฝังพร้อมเปิดใช้งาน jsapi (Return Embed URL with enablejsapi=1)
|
||||
return `https://www.youtube.com/embed/${videoId}?enablejsapi=1&rel=0`;
|
||||
});
|
||||
|
||||
// YouTube API Tracking
|
||||
// การติดตามผ่าน YouTube API (YouTube API Tracking)
|
||||
let ytPlayer: any = null;
|
||||
let ytInterval: any = null;
|
||||
|
||||
const initYoutubeAPI = () => {
|
||||
if (!isYoutube.value || typeof window === 'undefined') return;
|
||||
|
||||
// Load API Script if not exists
|
||||
// โหลดสคริปต์ API หากยังไม่มี (Load API Script if not exists)
|
||||
if (!(window as any).YT) {
|
||||
const tag = document.createElement('script');
|
||||
tag.src = "https://www.youtube.com/iframe_api";
|
||||
|
|
@ -83,7 +83,7 @@ const initYoutubeAPI = () => {
|
|||
'onReady': (event: any) => {
|
||||
duration.value = event.target.getDuration();
|
||||
|
||||
// Resume Logic for YouTube
|
||||
// ตรรกะการเล่นต่อสำหรับ YouTube (Resume Logic for YouTube)
|
||||
if (props.initialSeekTime && props.initialSeekTime > 0) {
|
||||
event.target.seekTo(props.initialSeekTime, true);
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ const startYTTracking = () => {
|
|||
currentTime.value = ytPlayer.getCurrentTime();
|
||||
emit('timeupdate', currentTime.value, duration.value);
|
||||
}
|
||||
}, 1000); // Check every second
|
||||
}, 1000); // ตรวจสอบทุกๆ วินาที (Check every second)
|
||||
};
|
||||
|
||||
const stopYTTracking = () => {
|
||||
|
|
@ -145,7 +145,7 @@ onUnmounted(() => {
|
|||
destroyYoutubePlayer();
|
||||
});
|
||||
|
||||
// Watch for src change to re-init
|
||||
// เฝ้าดูการเปลี่ยนแปลงของ src เพื่อเริ่มต้นใหม่ (Watch for src change to re-init)
|
||||
watch(() => props.src, (newSrc, oldSrc) => {
|
||||
if (newSrc !== oldSrc) {
|
||||
destroyYoutubePlayer();
|
||||
|
|
@ -174,8 +174,8 @@ const togglePlay = () => {
|
|||
playPromise.then(() => {
|
||||
isPlaying.value = true;
|
||||
}).catch(error => {
|
||||
// Auto-play was prevented or play was interrupted
|
||||
// We can safely ignore this error
|
||||
// การเล่นอัตโนมัติถูกป้องกันหรือถูกขัดจังหวะ (Auto-play was prevented or play was interrupted)
|
||||
// สามารถละเว้นข้อผิดพลาดนี้ได้อย่างปลอดภัย (We can safely ignore this error)
|
||||
console.log("Video play request handled:", error.name);
|
||||
});
|
||||
}
|
||||
|
|
@ -223,14 +223,14 @@ const handleVolumeChange = (val: any) => {
|
|||
setVolume(newVol);
|
||||
};
|
||||
|
||||
// Expose video ref for parent to control if needed
|
||||
// เปิดเผย video ref ให้คอมโพเนนต์แม่ควบคุมถ้าจำเป็น (Expose video ref for parent to control if needed)
|
||||
defineExpose({
|
||||
videoRef,
|
||||
pause: () => videoRef.value?.pause(),
|
||||
currentTime: () => videoRef.value?.currentTime || 0
|
||||
});
|
||||
|
||||
// Watch for volume/mute changes to apply to video element
|
||||
// เฝ้าดูการเปลี่ยนแปลงระดับเสียง/ปิดเสียง เพื่อนำไปใช้กับองค์ประกอบวิดีโอ (Watch for volume/mute changes to apply to video element)
|
||||
watch([volume, isMuted], () => {
|
||||
if (videoRef.value) applyTo(videoRef.value);
|
||||
});
|
||||
|
|
@ -238,7 +238,7 @@ watch([volume, isMuted], () => {
|
|||
|
||||
<template>
|
||||
<div class="bg-black rounded-xl overflow-hidden shadow-2xl mb-6 aspect-video relative group ring-1 ring-white/10">
|
||||
<!-- 1. YouTube Player -->
|
||||
<!-- 1. เครื่องเล่น YouTube (YouTube Player) -->
|
||||
<iframe
|
||||
v-if="isYoutube"
|
||||
id="youtube-iframe"
|
||||
|
|
@ -249,7 +249,7 @@ watch([volume, isMuted], () => {
|
|||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
<!-- 2. Standard HTML5 Video Player -->
|
||||
<!-- 2. เครื่องเล่นวิดีโอ HTML5 มาตรฐาน (Standard HTML5 Video Player) -->
|
||||
<div v-else class="w-full h-full relative group/video cursor-pointer">
|
||||
<video
|
||||
ref="videoRef"
|
||||
|
|
@ -262,9 +262,9 @@ watch([volume, isMuted], () => {
|
|||
@ended="handleEnded"
|
||||
/>
|
||||
|
||||
<!-- Custom Controls Overlay (Only for HTML5 Video) -->
|
||||
<!-- เลเยอร์ควบคุมแบบกำหนดเอง (Overlay) เฉพาะสำหรับวิดีโอ HTML5 เท่านั้น (Custom Controls Overlay (Only for HTML5 Video)) -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/90 via-black/40 to-transparent transition-opacity opacity-0 group-hover/video:opacity-100 flex flex-col gap-3">
|
||||
<!-- Progress Bar -->
|
||||
<!-- แถบแสดงความคืบหน้า (Progress Bar) -->
|
||||
<div class="relative flex-grow h-1.5 bg-white/20 rounded-full cursor-pointer group/progress overflow-hidden" @click="seek">
|
||||
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded-full group-hover/progress:bg-blue-400 transition-all shadow-[0_0_12px_rgba(59,130,246,0.6)]" :style="{ width: videoProgress + '%' }"></div>
|
||||
</div>
|
||||
|
|
@ -275,7 +275,7 @@ watch([volume, isMuted], () => {
|
|||
|
||||
<div class="flex-grow"></div>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<!-- ตัวควบคุมระดับเสียง (Volume Control) -->
|
||||
<div class="flex items-center gap-2 group/volume relative">
|
||||
<q-btn flat round dense :icon="volumeIcon" @click.stop="handleToggleMute" color="white" class="hover:scale-110 transition-transform" />
|
||||
<div class="w-0 group-hover/volume:w-24 overflow-hidden transition-all duration-300 flex items-center bg-black/60 backdrop-blur-md rounded-full px-2">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file FormInput.vue
|
||||
* @description Reusable input component with label, error handling, and support for disabled/required states.
|
||||
* Now supports password visibility toggle.
|
||||
* @description คอมโพเนนต์ช่องกรอกข้อมูล (Input) แบบนำกลับมาใช้ใหม่ได้ พร้อมรองรับข้อความป้ายกำกับ, จัดการข้อผิดพลาด และสถานะปิดใช้งาน/บังคับกรอก
|
||||
* รองรับการสลับซ่อน/แสดงรหัสผ่าน
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -16,19 +16,19 @@ const props = defineProps<{
|
|||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Update v-model value */
|
||||
/** อัปเดตค่า v-model (Update v-model value) */
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Password visibility state
|
||||
// สถานะการซ่อน/แสดงรหัสผ่าน (Password visibility state)
|
||||
const showPassword = ref(false)
|
||||
|
||||
// Toggle function
|
||||
// ฟังก์ชันสำหรับสลับสถานะ (Toggle function)
|
||||
const togglePassword = () => {
|
||||
showPassword.value = !showPassword.value
|
||||
}
|
||||
|
||||
// Compute input type based on visibility state
|
||||
// คำนวณประเภทของช่องกรอกข้อมูล بناءً pada state (Compute input type based on visibility state)
|
||||
const inputType = computed(() => {
|
||||
if (props.type === 'password') {
|
||||
return showPassword.value ? 'text' : 'password'
|
||||
|
|
@ -59,7 +59,7 @@ const updateValue = (event: Event) => {
|
|||
@input="updateValue"
|
||||
>
|
||||
|
||||
<!-- Password Toggle Button -->
|
||||
<!-- ปุ่มสลับซ่อน/แสดงรหัสผ่าน (Password Toggle Button) -->
|
||||
<button
|
||||
v-if="type === 'password'"
|
||||
type="button"
|
||||
|
|
@ -67,13 +67,13 @@ const updateValue = (event: Event) => {
|
|||
@click="togglePassword"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Eye Icon (Show) -->
|
||||
<!-- ไอคอนเปิดตา (แสดงรหัสผ่าน) (Eye Icon - Show) -->
|
||||
<svg v-if="!showPassword" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
|
||||
<!-- Eye Off Icon (Hide) -->
|
||||
<!-- ไอคอนปิดตา (ซ่อนรหัสผ่าน) (Eye Off Icon - Hide) -->
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file GlobalLoader.vue
|
||||
* @description Global full-screen loading overlay that triggers during page navigation.
|
||||
* Uses a premium pulsing logo animation.
|
||||
* @description คอมโพเนนต์หน้าจอโหลดแบบเต็มจอ (Global full-screen loading) แสดงผลตอนเปลี่ยนหน้า
|
||||
* พร้มแอนิเมชันโลโก้ขยับได้แบบพรีเมียม
|
||||
*/
|
||||
|
||||
const nuxtApp = useNuxtApp()
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Hook into Nuxt page transitions
|
||||
// ดักจับจังหวะการเปลี่ยนหน้าผ่าย Nuxt hook (Hook into Nuxt page transitions)
|
||||
nuxtApp.hook('page:start', () => {
|
||||
isLoading.value = true
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
// Add a small delay for better UX (prevents flickering on fast loads)
|
||||
// หน่วงเวลาเล็กน้อยเพื่อให้ไหลลื่น ไม่กระพริบเร็วไปหากหน้าโหลดเสร็จไว (Add a small delay for better UX)
|
||||
setTimeout(() => {
|
||||
isLoading.value = false
|
||||
}, 500)
|
||||
|
|
@ -25,14 +25,14 @@ nuxtApp.hook('page:finish', () => {
|
|||
<Transition name="fade">
|
||||
<div v-if="isLoading" class="fixed inset-0 z-[99999] flex flex-col items-center justify-center bg-white dark:bg-[#0f172a] transition-colors duration-300">
|
||||
<div class="relative flex flex-col items-center">
|
||||
<!-- Main Logo Box -->
|
||||
<!-- กล่องโลโก้หลัก (Main Logo Box) -->
|
||||
<div class="w-20 h-20 bg-blue-50 dark:bg-blue-900/20 rounded-2xl flex items-center justify-center mb-6 animate-pulse-soft">
|
||||
<div class="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-600/30 animate-bounce-subtle">
|
||||
<span class="text-2xl font-black text-white">E</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Text -->
|
||||
<!-- ข้อความระหว่างโหลด (Loading Text) -->
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<h3 class="text-lg font-bold text-slate-800 dark:text-white tracking-wide">e-Learning</h3>
|
||||
<div class="flex gap-1">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file LanguageSwitcher.vue
|
||||
* @description Language switcher component using Quasar dropdown.
|
||||
* Allows switching between Thai (th) and English (en) locales.
|
||||
* @description คอมโพเนนต์ตัวสลับภาษาใช้ปุ่ม Dropdown ของ Quasar
|
||||
* ใช้สลับระหว่างภาษาไทย (th) และภาษาอังกฤษ (en)
|
||||
*/
|
||||
|
||||
const { locale, setLocale, locales } = useI18n()
|
||||
|
||||
// Get available locales with their names
|
||||
// ดึงรายการภาษาที่มีอยู่พร้อมชื่อภาษา (Get available locales with their names)
|
||||
const availableLocales = computed(() => {
|
||||
return (locales.value as Array<{ code: string; name: string }>).map((loc) => ({
|
||||
code: loc.code,
|
||||
|
|
@ -15,13 +15,13 @@ const availableLocales = computed(() => {
|
|||
}))
|
||||
})
|
||||
|
||||
// Get flag image path for a locale
|
||||
// ดึงพาธภาพธงชาติสำหรับภาษานั้นๆ (Get flag image path for a locale)
|
||||
const getFlagPath = (code: string) => `/flags/${code}.png`
|
||||
|
||||
// Handle locale change
|
||||
// จัดการเมื่อเปลี่ยนภาษา (Handle locale change)
|
||||
const changeLocale = async (code: string) => {
|
||||
await setLocale(code as 'th' | 'en')
|
||||
// Cookie is automatically handled by @nuxtjs/i18n with detectBrowserLanguage.useCookie
|
||||
// คุกกี้ (Cookie) จะถูกจัดการอัตโนมัติโดย @nuxtjs/i18n จากออปชัน detectBrowserLanguage.useCookie
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ const changeLocale = async (code: string) => {
|
|||
class="language-btn"
|
||||
:aria-label="$t('language.label')"
|
||||
>
|
||||
<!-- Show current locale flag -->
|
||||
<!-- แสดงธงชาติตามภาษาที่ใช้อยู่ (Show current locale flag) -->
|
||||
<img
|
||||
:src="getFlagPath(locale)"
|
||||
:alt="locale.toUpperCase()"
|
||||
|
|
@ -178,7 +178,7 @@ const changeLocale = async (code: string) => {
|
|||
</style>
|
||||
|
||||
<style>
|
||||
/* Global styles for teleported menu */
|
||||
/* สไตล์ Global สำหรับเมนูที่ถูกข้ามไปแสดงผลที่อื่นด้วย Teleport (Global styles for teleported menu) */
|
||||
.language-menu {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file LoadingSkeleton.vue
|
||||
* @description คอมโพเนนต์ Skeleton สำหรับแสดงโครงร่างหน้าจอระหว่างรอโหลดข้อมูล (Loading Skeleton Component)
|
||||
*/
|
||||
defineProps<{
|
||||
type?: 'text' | 'avatar' | 'card' | 'button'
|
||||
width?: string
|
||||
|
|
@ -9,6 +13,7 @@ defineProps<{
|
|||
|
||||
<template>
|
||||
<div class="skeleton-wrapper">
|
||||
<!-- กรณีเป็นโครงร่างประเภทการ์ด (Card type skeleton) -->
|
||||
<template v-if="type === 'card'">
|
||||
<div v-for="i in (count || 1)" :key="i" class="skeleton-card">
|
||||
<div class="skeleton skeleton-image"/>
|
||||
|
|
@ -20,14 +25,17 @@ defineProps<{
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- กรณีเป็นโครงร่างประเภทรูปโปรไฟล์ (Avatar type skeleton) -->
|
||||
<template v-else-if="type === 'avatar'">
|
||||
<div class="skeleton skeleton-avatar"/>
|
||||
</template>
|
||||
|
||||
<!-- กรณีเป็นโครงร่างประเภทปุ่มกด (Button type skeleton) -->
|
||||
<template v-else-if="type === 'button'">
|
||||
<div class="skeleton skeleton-button" :style="{ width: width || '120px' }"/>
|
||||
</template>
|
||||
|
||||
<!-- กรณีอื่นๆ จะแสดงเป็นบรรทัดข้อความ (Fallback/Text type skeleton) -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="i in (count || 1)"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file LoadingSpinner.vue
|
||||
* @description ไอคอนหมุนแสดงการโหลด (Loading Spinner Component) เหมาะสำหรับใช้ตรงจุดเล็กๆ หรือตอนโหลดหน้าเว็บ
|
||||
*/
|
||||
defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
text?: string
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file CourseCard.vue
|
||||
* @description Standardized Course Card Component.
|
||||
* Usage: <CourseCard :id="1" title="..." ... />
|
||||
* @description คอมโพเนนต์การ์ดแสดงคอร์สเรียนมาตรฐาน (Standardized Course Card Component)
|
||||
* วิธีใช้งาน: <CourseCard :id="1" title="..." ... />
|
||||
*/
|
||||
|
||||
interface CourseCardProps {
|
||||
|
|
@ -20,7 +20,7 @@ interface CourseCardProps {
|
|||
image?: string
|
||||
loading?: boolean
|
||||
|
||||
// Action Flags
|
||||
// ตัวควบคุมการแสดงปุ่มต่างๆ (Action Flags)
|
||||
showViewDetails?: boolean
|
||||
showContinue?: boolean
|
||||
showCertificate?: boolean
|
||||
|
|
@ -59,7 +59,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
<template>
|
||||
<div class="group relative flex flex-col bg-white dark:!bg-slate-900 rounded-3xl overflow-hidden border border-slate-200 dark:border-white/5 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full">
|
||||
|
||||
<!-- Thumbnail Section -->
|
||||
<!-- ส่วนรูปหน้าปก (Thumbnail Section) -->
|
||||
<div class="relative w-full aspect-video overflow-hidden">
|
||||
<img
|
||||
v-if="image"
|
||||
|
|
@ -71,12 +71,12 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
<q-icon name="image" size="48px" class="text-slate-300 dark:text-slate-600" />
|
||||
</div>
|
||||
|
||||
<!-- Overlays -->
|
||||
<!-- เลเยอร์ฟิลเตอร์ซ้อนทับ (Overlays) -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
|
||||
|
||||
|
||||
<!-- Completed Badge -->
|
||||
<!-- ป้ายแสดงสถานะเรียนจบ (Completed Badge) -->
|
||||
<div v-if="completed" class="absolute inset-0 bg-emerald-900/40 backdrop-blur-[2px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<span class="bg-emerald-500 text-white w-12 h-12 rounded-full flex items-center justify-center shadow-lg transform scale-75 group-hover:scale-100 transition-transform">
|
||||
<q-icon name="check" size="24px" />
|
||||
|
|
@ -84,9 +84,9 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Section -->
|
||||
<!-- ส่วนเนื้อหาข้อมูล (Content Section) -->
|
||||
<div class="p-6 flex flex-col flex-grow">
|
||||
<!-- Meta Info (Lessons/Duration) -->
|
||||
<!-- ข้อมูลประกอบย่อย เช่น บทเรียน/ระยะเวลา (Meta Info - Lessons/Duration) -->
|
||||
<div class="flex items-center gap-3 text-xs font-bold text-slate-500 dark:text-slate-400 mb-3 uppercase tracking-wider">
|
||||
<span v-if="lessons" class="flex items-center gap-1">
|
||||
<q-icon name="menu_book" size="14px" /> {{ lessons }} {{ $t('course.lessonsUnit') }}
|
||||
|
|
@ -96,18 +96,18 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<!-- ชื่อคอร์ส (Title) -->
|
||||
<h3 class="text-lg font-black text-slate-900 dark:text-white mb-2 line-clamp-2 leading-tight group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||
{{ displayTitle }}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<!-- รายละเอียดเพิ่มเติม (Description) -->
|
||||
<p v-if="displayDescription" class="text-sm text-slate-500 dark:text-slate-400 line-clamp-2 mb-6">
|
||||
{{ displayDescription }}
|
||||
</p>
|
||||
|
||||
<div class="mt-auto pt-4">
|
||||
<!-- Progress Bar -->
|
||||
<!-- หลอดความคืบหน้า (Progress Bar) -->
|
||||
<div v-if="progress !== undefined && !completed && !hideProgress" class="mb-4">
|
||||
<div class="flex justify-between text-[10px] font-bold uppercase mb-1">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
|
||||
|
|
@ -118,9 +118,9 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<!-- ปุ่มปฏิบัติการต่างๆ (Action Buttons) -->
|
||||
<div v-if="!hideActions" class="flex flex-col gap-3">
|
||||
<!-- View Details (Secondary Action) -->
|
||||
<!-- ปุ่มดูรายละเอียด (ปุ่มรอง) (View Details - Secondary Action) -->
|
||||
<q-btn
|
||||
v-if="showViewDetails && !completed && !progress"
|
||||
flat
|
||||
|
|
@ -130,7 +130,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
:to="`/course/${id}`"
|
||||
/>
|
||||
|
||||
<!-- Continue Learning (Primary Action) -->
|
||||
<!-- ปุ่มเรียนต่อ/เริ่มเรียน (ปุ่มหลัก) (Continue Learning - Primary Action) -->
|
||||
<q-btn
|
||||
v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)"
|
||||
unelevated
|
||||
|
|
@ -142,7 +142,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
</div>
|
||||
|
||||
<div v-if="completed" class="space-y-2">
|
||||
<!-- Study Again -->
|
||||
<!-- ปุ่มเรียนอีกครั้ง (Study Again) -->
|
||||
<q-btn
|
||||
v-if="showStudyAgain"
|
||||
unelevated
|
||||
|
|
@ -152,7 +152,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
:to="`/classroom/learning?course_id=${id}`"
|
||||
/>
|
||||
|
||||
<!-- Download Certificate -->
|
||||
<!-- ปุ่มดาวน์โหลดใบรับรอง (Download Certificate) -->
|
||||
<q-btn
|
||||
v-if="showCertificate"
|
||||
unelevated
|
||||
|
|
@ -168,5 +168,5 @@ const displayCategory = computed(() => getLocalizedText(props.category))
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped overrides if needed */
|
||||
/* ใส่โค้ด CSS เพิ่มได้ถ้าต้องการครอบคลุมเฉพาะไฟล์นี้ (Scoped overrides if needed) */
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file CategorySidebar.vue
|
||||
* @description Sidebar for filtering courses by category
|
||||
* @description แถบเมนูด้านข้างสำหรับกรองคอร์สตามหมวดหมู่ (Sidebar for filtering courses by category)
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -81,13 +81,13 @@ const toggleCategory = (id: number) => {
|
|||
{{ getLocalizedText(cat.name) }}
|
||||
</span>
|
||||
|
||||
<!-- Active Indicator Dot -->
|
||||
<!-- จุดแสดงสถานะเมื่อถูกเลือก (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 -->
|
||||
<!-- ปุ่มแสดงเพิ่มเติม/แสดงน้อยลง (Show More/Less Action) -->
|
||||
<div
|
||||
v-if="categories.length > 5"
|
||||
@click="showAllCategories = !showAllCategories"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file CourseDetailView.vue
|
||||
* @description Quick view of course details including video preview, curriculum, and enroll logic
|
||||
* @description แสดงรายละเอียดคอร์สแบบรวดเร็ว รวมถึงตัวอย่างวิดีโอ, หลักสูตร, และระบบการลงทะเบียน
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
|
|
@ -51,9 +51,9 @@ const handleEnroll = () => {
|
|||
if(!props.course) return;
|
||||
enrollmentLoading.value = true;
|
||||
emit('enroll', props.course.id);
|
||||
// Loading state reset depends on parent, but locally we can reset after emit or keep until prop changes
|
||||
// In this pattern, we just emit.
|
||||
setTimeout(() => enrollmentLoading.value = false, 2000); // Safety timeout
|
||||
// สถานะ Loading ถูกจัดการโดยคอมโพเนนต์แม่ แต่ในคอมโพเนนต์นี้เรารีเซ็ตหลังจากส่ง event หรือเก็บไว้จนกว่า prop จะเปลี่ยน
|
||||
// ในรูปแบบนี้ เราแค่ส่ง event ออกไป (just emit)
|
||||
setTimeout(() => enrollmentLoading.value = false, 2000); // ตั้งเวลารีเซ็ตเพื่อความปลอดภัย (Safety timeout)
|
||||
};
|
||||
const instructorData = computed(() => {
|
||||
if (props.course?.instructors && props.course.instructors.length > 0) {
|
||||
|
|
@ -67,10 +67,10 @@ const instructorData = computed(() => {
|
|||
<template>
|
||||
<div class="animate-fade-in-up">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Left: Content Detail -->
|
||||
<!-- ด้านซ้าย: รายละเอียดเนื้อหา (Left: Content Detail) -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
|
||||
<!-- Video Preview Section -->
|
||||
<!-- ส่วนแสดงตัวอย่างวิดีโอ (Video Preview Section) -->
|
||||
<div class="relative aspect-video rounded-3xl overflow-hidden shadow-2xl group cursor-pointer bg-slate-900 border-4 border-white dark:border-slate-800 transition-transform duration-500 hover:scale-[1.01]">
|
||||
<template v-if="course.media?.video_url">
|
||||
<video
|
||||
|
|
@ -81,19 +81,19 @@ const instructorData = computed(() => {
|
|||
<source :src="course.media.video_url" type="video/mp4">
|
||||
{{ $t('course.videoNotSupported') }}
|
||||
</video>
|
||||
<!-- Custom Play Overlay when not playing - simple version is often best -->
|
||||
<!-- ปุ่มเล่นวิดีโอแบบปรับแต่งเองตอนยังไม่เล่น (Custom Play Overlay when not playing) -->
|
||||
</template>
|
||||
<!-- Beautiful Image Showcase if no video -->
|
||||
<!-- แสดงรูปภาพสวยๆ กรณีที่ไม่มีวิดีโอ (Beautiful Image Showcase if no video) -->
|
||||
<template v-else>
|
||||
<div class="w-full h-full flex items-center justify-center relative overflow-hidden bg-slate-950 group">
|
||||
<!-- Blurred background fill -->
|
||||
<!-- รูปพื้นหลังเบลอ (Blurred background fill) -->
|
||||
<img
|
||||
v-if="course.thumbnail_url || course.cover_image"
|
||||
:src="course.thumbnail_url || course.cover_image"
|
||||
class="absolute inset-0 w-full h-full object-cover opacity-40 blur-2xl scale-125"
|
||||
/>
|
||||
|
||||
<!-- Main Sharp Image -->
|
||||
<!-- รูปหลักแบบคมชัด (Main Sharp Image) -->
|
||||
<img
|
||||
v-if="course.thumbnail_url || course.cover_image"
|
||||
:src="course.thumbnail_url || course.cover_image"
|
||||
|
|
@ -107,7 +107,7 @@ const instructorData = computed(() => {
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Course Title & Description -->
|
||||
<!-- ชื่อคอร์สและรายละเอียด (Course Title & Description) -->
|
||||
<div>
|
||||
<h1 class="text-3xl md:text-4xl font-extrabold text-slate-900 dark:text-white mb-4 leading-tight">
|
||||
{{ getLocalizedText(course.title) }}
|
||||
|
|
@ -118,10 +118,10 @@ const instructorData = computed(() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Course Detail - Single Page Layout -->
|
||||
<!-- รายละเอียดคอร์ส - รูแแบบหน้าเดียว (Course Detail - Single Page Layout) -->
|
||||
<div class="space-y-10">
|
||||
|
||||
<!-- Instructor Info -->
|
||||
<!-- ข้อมูลผู้สอน (Instructor Info) -->
|
||||
<div class="flex flex-col sm:flex-row gap-6 items-start sm:items-center pb-8 border-b border-slate-200 dark:border-slate-800">
|
||||
<q-avatar size="64px">
|
||||
<img :src="instructorData?.profile?.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" />
|
||||
|
|
@ -135,7 +135,7 @@ const instructorData = computed(() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Curriculum / Lesson Details -->
|
||||
<!-- รายละเอียดหลักสูตร / บทเรียน (Curriculum / Lesson Details) -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-slate-900 dark:text-white">
|
||||
|
|
@ -148,7 +148,7 @@ const instructorData = computed(() => {
|
|||
|
||||
<div class="space-y-4">
|
||||
<div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="group">
|
||||
<!-- Chapter Header -->
|
||||
<!-- ส่วนหัวของบท (Chapter Header) -->
|
||||
<div class="px-6 py-4 bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-white/5 font-black text-slate-800 dark:text-white flex justify-between items-center mb-2 shadow-sm">
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="w-7 h-7 flex items-center justify-center bg-slate-100 dark:bg-white/10 rounded-lg text-xs font-bold font-mono">{{ Number(idx) + 1 }}</span>
|
||||
|
|
@ -157,7 +157,7 @@ const instructorData = computed(() => {
|
|||
<span class="text-[10px] uppercase font-black tracking-widest text-slate-400 opacity-60">{{ chapter.lessons?.length || 0 }} {{ $t('course.lessonsUnit') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Lessons List -->
|
||||
<!-- รายการบทเรียน (Lessons List) -->
|
||||
<div class="ml-4 pl-4 border-l-2 border-slate-100 dark:border-slate-800 space-y-1 mt-3">
|
||||
<div v-for="lesson in chapter.lessons" :key="lesson.id" class="px-5 py-3 flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-white/5 rounded-xl transition-all hover:translate-x-1">
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0" :class="lesson.type === 'VIDEO' ? 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400' : 'bg-orange-50 text-orange-600 dark:bg-orange-500/10 dark:text-orange-400'">
|
||||
|
|
@ -173,7 +173,7 @@ const instructorData = computed(() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<!-- กรณีที่ไม่มีข้อมูล (Empty State) -->
|
||||
<div v-if="!course.chapters || course.chapters.length === 0" class="flex flex-col items-center justify-center py-12 text-slate-400 dark:text-slate-500 bg-white/50 dark:bg-slate-900/50 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800">
|
||||
<q-icon name="menu_book" size="40px" class="mb-2 opacity-50" />
|
||||
<p class="text-sm font-medium">{{ $t('course.noContent') }}</p>
|
||||
|
|
@ -185,11 +185,11 @@ const instructorData = computed(() => {
|
|||
|
||||
</div>
|
||||
|
||||
<!-- Right: Enrollment Card -->
|
||||
<!-- ด้านขวา: การ์ดลงทะเบียน (Right: Enrollment Card) -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="sticky top-24">
|
||||
<div class="bg-white dark:bg-slate-900 rounded-3xl shadow-2xl shadow-blue-500/10 dark:shadow-none p-8 border border-slate-100 dark:border-white/5 relative overflow-hidden group">
|
||||
<!-- Decorative background glow -->
|
||||
<!-- ลูกเล่นแสงพื้นหลังตกแต่ง (Decorative background glow) -->
|
||||
<div class="absolute -top-12 -right-12 w-48 h-48 bg-blue-500/10 rounded-full blur-3xl group-hover:bg-blue-500/20 transition-colors"></div>
|
||||
|
||||
<div class="relative">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file AppHeader.vue
|
||||
* @description The main header for the EduLearn application dashboard.
|
||||
* @description แถบเมนูด้านบนหลัก (Header) สำหรับหน้าแดชบอร์ด (Dashboard) ของระบบ EduLearn
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
/** Controls visibility of the sidebar toggle button */
|
||||
/** ควบคุมการแสดงผลของปุ่มเปิด/ปิดแถบเมนูด้านข้าง (Sidebar) */
|
||||
showSidebarToggle?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Emitted when the hamburger menu is clicked */
|
||||
/** ส่งสัญญาณ (Emit) เมื่อผู้ใช้คลิกที่ปุ่มแฮมเบอร์เกอร์เมนู */
|
||||
toggleSidebar: [];
|
||||
}>();
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ const toggleTheme = () => {
|
|||
<template>
|
||||
<q-toolbar class="bg-white dark:!bg-[#020617] text-slate-900 dark:!text-white h-20 border-b border-slate-50 dark:border-slate-800/50 px-6">
|
||||
|
||||
<!-- Left: Hamburger Toggle -->
|
||||
<!-- ด้านซ้าย: ปุ่มย่อขยายแถบเมนูด้านข้าง (Hamburger Toggle) -->
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
|
|
@ -43,10 +43,10 @@ const toggleTheme = () => {
|
|||
|
||||
<q-space />
|
||||
|
||||
<!-- Right Section -->
|
||||
<!-- ส่วนการตั้งค่าทางด้านขวา (Right Section) -->
|
||||
<div class="flex items-center gap-2 sm:gap-4 md:gap-6 no-wrap">
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<!-- ปุ่มสลับธีม (Theme Toggle) -->
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
|
|
@ -60,7 +60,7 @@ const toggleTheme = () => {
|
|||
<q-tooltip>{{ isDark ? 'โหมดกลางคืน' : 'โหมดกลางวัน' }}</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<!-- Language Switcher (Pill Style) -->
|
||||
<!-- ตัวสลับภาษาแบบแคปซูล (Language Switcher) -->
|
||||
<div
|
||||
@click="toggleLanguage"
|
||||
class="flex items-center bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800 rounded-xl p-0.5 sm:p-1 cursor-pointer hover:bg-slate-100 transition-all font-bold text-[11px] sm:text-[13px] select-none"
|
||||
|
|
@ -70,7 +70,7 @@ const toggleTheme = () => {
|
|||
<div :class="locale === 'en' ? 'bg-white dark:bg-slate-700 shadow-sm text-blue-600' : 'text-slate-400'" class="px-2 sm:px-3 py-1 rounded-lg transition-all">EN</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<!-- เส้นคั่น (Divider) -->
|
||||
<div class="hidden sm:block w-[1px] h-8 bg-slate-100 dark:bg-slate-800"></div>
|
||||
|
||||
<!-- ส่วนข้อมูลผู้ใช้งาน (User Profile) -->
|
||||
|
|
@ -102,12 +102,12 @@ const toggleTheme = () => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Ensure toolbar height is consistent */
|
||||
/* บังคับให้ความสูงของ Header เท่ากันเสมอ (Ensure toolbar height is consistent) */
|
||||
:deep(.q-toolbar) {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Hide user name only on small mobile screens */
|
||||
/* ซ่อนชื่อผู้ใช้ไว้เฉพาะบนหน้าจอมือถือขนาดเล็กเท่านั้น (Hide user name only on small mobile screens) */
|
||||
@media (max-width: 600px) {
|
||||
.user-info-text {
|
||||
display: none !important;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const handleLogout = () => {
|
|||
<template>
|
||||
<div class="flex flex-col h-full bg-white dark:!bg-[#04091a] px-4 py-6 border-r border-slate-100 dark:border-slate-800">
|
||||
|
||||
<!-- Logo Section -->
|
||||
<!-- โลโก้แบรนด์ (Logo Section) -->
|
||||
<div class="flex items-center gap-3 px-2 mb-10 transition-transform active:scale-95 cursor-pointer" @click="navigateTo('/dashboard')">
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||
<q-icon name="school" color="white" size="24px" />
|
||||
|
|
@ -59,7 +59,7 @@ const handleLogout = () => {
|
|||
<span class="text-[22px] font-black tracking-tight text-slate-800 dark:text-white">EduLearn</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Navigation -->
|
||||
<!-- การนำทางหลัก (Main Navigation) -->
|
||||
<div class="space-y-1 mb-8">
|
||||
<NuxtLink
|
||||
v-for="item in menuItems"
|
||||
|
|
@ -71,12 +71,12 @@ const handleLogout = () => {
|
|||
<q-icon :name="item.icon" size="24px" class="transition-colors" />
|
||||
<span class="font-bold text-[15px]">{{ item.label }}</span>
|
||||
|
||||
<!-- Active Indicator -->
|
||||
<!-- ตัวบ่งชี้หน้าปัจจุบัน (Active Indicator) -->
|
||||
<div v-if="route.path === item.to" class="absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-6 bg-blue-600 rounded-r-full shadow-[2px_0_8px_rgba(37,99,235,0.4)]"></div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
<!-- หมวดหมู่บัญชี (Account Section) -->
|
||||
<div class="px-4 mb-4">
|
||||
<span class="text-[12px] font-bold text-slate-400 uppercase tracking-widest">{{ $t('sidebar.accountGroup') }}</span>
|
||||
</div>
|
||||
|
|
@ -92,7 +92,7 @@ const handleLogout = () => {
|
|||
<span class="font-bold text-[15px]">{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<!-- ปุ่มออกจากระบบ (Logout Button) -->
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all text-red-500 hover:bg-red-50 dark:hover:bg-red-900/10 font-bold text-[15px] group"
|
||||
|
|
@ -104,7 +104,7 @@ const handleLogout = () => {
|
|||
|
||||
<q-space />
|
||||
|
||||
<!-- Promo Card -->
|
||||
<!-- การ์ดโปรโมชั่น (Promo Card) -->
|
||||
<div class="mt-auto p-5 rounded-[2rem] bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800 relative overflow-hidden group">
|
||||
<div class="relative z-10">
|
||||
<h4 class="font-black text-slate-800 dark:text-white text-sm mb-1">{{ $t('sidebar.promoTitle') }}</h4>
|
||||
|
|
@ -118,7 +118,7 @@ const handleLogout = () => {
|
|||
</q-btn>
|
||||
</div>
|
||||
|
||||
<!-- Subtle background decoration -->
|
||||
<!-- การตกแต่งพื้นหลังแบบจางๆ (Subtle background decoration) -->
|
||||
<div class="absolute -right-2 -bottom-2 w-16 h-16 bg-blue-500/5 rounded-full blur-xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file LandingFooter.vue
|
||||
* @description Footer component for the landing page - Adjusted to Image 2 (E-Learning Platform Branding)
|
||||
* @description ส่วนท้ายของหน้าแรก (Footer component for the landing page)
|
||||
*/
|
||||
</script>
|
||||
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
<footer class="bg-white pt-16 pb-8 border-t border-slate-200">
|
||||
<div class="container mx-auto px-6 md:px-12">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 mb-12 text-left">
|
||||
<!-- Brand -->
|
||||
<!-- โลโก้และชื่อแบรนด์ (Brand) -->
|
||||
<div class="space-y-6">
|
||||
<NuxtLink to="/" class="flex items-center gap-3 group">
|
||||
<div class="bg-blue-600 text-white font-black rounded-full px-6 w-10 h-10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<!-- ลิงก์ต่างๆ (Links) -->
|
||||
<div class="lg:pl-8">
|
||||
<h4 class="font-bold text-slate-900 mb-6 text-base tracking-tight">คอร์สเรียน</h4>
|
||||
<ul class="space-y-3 text-sm text-slate-500 flex flex-col gap-2">
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Support -->
|
||||
<!-- การสนับสนุนผู้ใช้ (Support) -->
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-900 mb-6 text-base">ช่วยเหลือ</h4>
|
||||
<ul class="space-y-3 text-sm text-slate-500 flex flex-col gap-2">
|
||||
|
|
@ -50,11 +50,11 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Contact (Bronco Hourse Data) -->
|
||||
<!-- ข้อมูลการติดต่อ (Contact) -->
|
||||
<div class="space-y-6">
|
||||
<h4 class="font-bold text-slate-900 text-base">ติดต่อเรา</h4>
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- Location -->
|
||||
<!-- สถานที่ตั้ง (Location) -->
|
||||
<div class="flex flex-row items-start gap-4 flex-nowrap">
|
||||
<q-icon name="o_location_on" size="20px" color="slate-800" />
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
|
|
@ -65,18 +65,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<!-- เบอร์โทรศัพท์ (Phone) -->
|
||||
<div class="flex flex-row items-center gap-4 flex-nowrap">
|
||||
<q-icon name="o_phone" size="18px" color="slate-800" />
|
||||
<a href="tel:052-076-025" class="font-semibold text-slate-800 text-sm hover:text-blue-600 font-semibold text-sm transition-colors truncate">
|
||||
<a href="tel:052-076-025" class="font-semibold text-slate-800 text-sm hover:text-blue-600 transition-colors truncate">
|
||||
052-076-025
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<!-- อีเมล (Email) -->
|
||||
<div class="flex flex-row items-center gap-4 flex-nowrap">
|
||||
<q-icon name="o_email" size="18px" color="slate-800" />
|
||||
<a href="mailto:info@chamomind.com" class="font-semibold text-slate-800 text-sm hover:text-blue-600 font-semibold text-sm transition-colors truncate">
|
||||
<a href="mailto:info@chamomind.com" class="font-semibold text-slate-800 text-sm hover:text-blue-600 transition-colors truncate">
|
||||
info@chamomind.com
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar (Centered Copyright) -->
|
||||
<!-- แถบด้านล่างสำหรับสงวนลิขสิทธิ์ (Bottom Bar - Centered Copyright) -->
|
||||
<div class="pt-8 border-t border-slate-200 text-center">
|
||||
<p class="text-sm text-slate-400 font-medium tracking-wide">
|
||||
Copyright © CHAMOMIND CO., LTD. 2023
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file LandingHeader.vue
|
||||
* @description The main header for the public landing pages.
|
||||
* Features a transparent background that becomes solid/glass upon scrolling.
|
||||
* Responsive: Collapses into a drawer on mobile (md breakpoint).
|
||||
* @description คอมโพเนนต์แถบเมนูด้านบน (Header Navigation) สำหรับหน้า Landing Page และหน้าเปิดอื่นๆ
|
||||
* รองรับการเปลี่ยนภาษา เปลี่ยนโหมดสว่าง/มืด และเข้าถึงเมนูผู้ใช้ (Profile/Logout)
|
||||
*/
|
||||
const text = ref('');
|
||||
|
||||
// Track scrolling state to adjust header styling
|
||||
// ติดตามสถานะการเลื่อนเมาส์ (scroll) เพื่อปรับรูปแบบของ Header
|
||||
const isScrolled = ref(false)
|
||||
const { isAuthenticated } = useAuth()
|
||||
// Mobile Drawer State
|
||||
// สถานะเมนูสำหรับหน้าจอมือถือ (Mobile Drawer State)
|
||||
// ตัวแปรควบคุมการเปิด/ปิดเมนูบนหน้าจอขนาดเล็ก (Mobile Drawer)
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
const handleScroll = () => {
|
||||
isScrolled.value = window.scrollY > 20
|
||||
}
|
||||
|
||||
// Lock body scroll when mobile menu is open
|
||||
// ปิดการเลื่อนหน้าเว็บ (Lock body scroll) เมื่อเปิดเมนูบนมือถือ
|
||||
watch(mobileMenuOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
|
@ -32,21 +31,21 @@ onMounted(() => {
|
|||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
document.body.style.overflow = '' // Cleanup
|
||||
document.body.style.overflow = '' // คืนค่าเริ่มต้น (Cleanup)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
Header Container
|
||||
- Transitions between transparent and glass effect based on scroll.
|
||||
คอนเทนเนอร์ของ Header (Header Container)
|
||||
- เปลี่ยนจากสีใส (transparent) เป็นเอฟเฟกต์กระจก (glass effect) เมื่อเลื่อนเมาส์ลง
|
||||
-->
|
||||
<header
|
||||
class="fixed top-0 left-0 right-0 z-[100] transition-all"
|
||||
:class="[isScrolled ? 'h-20 glass-nav shadow-lg' : 'h-20 bg-transparent duration-300 border-b border-b-grey-7 ']"
|
||||
>
|
||||
<div class="container mx-auto px-6 md:px-12 h-full flex items-center justify-between">
|
||||
<!-- Left Section: Logo -->
|
||||
<!-- ด้านซ้าย: โลโก้แบรนด์ (Left Section: Logo) -->
|
||||
<NuxtLink to="/" class="flex items-center gap-3 group">
|
||||
<div class="bg-blue-600 text-white font-black rounded-full px-6 w-10 h-10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<q-icon name="o_school" size="24px" />
|
||||
|
|
@ -67,7 +66,7 @@ onUnmounted(() => {
|
|||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Desktop Navigation (Visible by default, hidden on mobile via CSS 'desktop-nav') -->
|
||||
<!-- การนำทางสำหรับเดสก์ท็อป (แสดงผลเป็นค่าเริ่มต้น, ซ่อนบนมือถือผ่าน CSS 'desktop-nav') -->
|
||||
<!-- <nav class="flex desktop-nav items-center gap-8 text-[16px] font-medium">
|
||||
<NuxtLink
|
||||
to="/browse"
|
||||
|
|
@ -89,10 +88,10 @@ onUnmounted(() => {
|
|||
|
||||
|
||||
|
||||
<!-- Desktop Action Buttons (Visible by default, hidden on mobile via CSS 'desktop-nav') -->
|
||||
<!-- ปุ่มปฏิบัติการสำหรับเดสก์ท็อป (แสดงผลเป็นค่าเริ่มต้น, ซ่อนบนมือถือผ่าน CSS 'desktop-nav') -->
|
||||
<div class="flex desktop-nav items-center gap-4">
|
||||
<template v-if="!isAuthenticated">
|
||||
<!-- Login Button -->
|
||||
<!-- ปุ่มเข้าสู่ระบบ (Login Button) -->
|
||||
<NuxtLink
|
||||
to="/auth/login"
|
||||
class="px-5 py-4 rounded-full text-slate-700 font-semibold text-sm transition-all hover:-translate-y-0.5"
|
||||
|
|
@ -101,7 +100,7 @@ onUnmounted(() => {
|
|||
{{ $t('auth.login') }}
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Register Button -->
|
||||
<!-- ปุ่มสมัครสมาชิก (Register Button) -->
|
||||
<NuxtLink
|
||||
to="/auth/register"
|
||||
class="px-5 py-4 rounded-full bg-blue-600 text-white font-semibold text-sm hover:shadow-blue-600/40 hover:-translate-y-0.5 transition-all"
|
||||
|
|
@ -120,7 +119,7 @@ onUnmounted(() => {
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button (Visible on Mobile) -->
|
||||
<!-- ปุ่มเปิดเมนูบนมือถือ (แสดงผลเฉพาะบน Mobile) -->
|
||||
<button
|
||||
class="md:hidden mobile-toggle ml-auto relative z-[120] w-10 h-10 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="[isScrolled ? 'text-white hover:bg-white/10' : 'text-slate-900 hover:bg-slate-100', mobileMenuOpen ? 'text-slate-900 z-[120]' : '']"
|
||||
|
|
@ -132,7 +131,7 @@ onUnmounted(() => {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Mobile Navigation Drawer (Teleported to body to avoid z-index/clipping issues with Header) -->
|
||||
<!-- ลิ้นชักเมนูด้านข้างสำหรับมือถือ (Mobile Navigation Drawer - แยกส่วนไปยัง body เพื่อไม่ให้มีปัญหา z-index หรือถูกบัง) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="mobileMenuOpen">
|
||||
<div
|
||||
|
|
@ -146,7 +145,7 @@ onUnmounted(() => {
|
|||
:class="[mobileMenuOpen ? 'translate-x-0' : 'translate-x-full']"
|
||||
>
|
||||
<div class="p-6 pt-8 flex flex-col gap-6 h-full overflow-y-auto relative">
|
||||
<!-- Close Button -->
|
||||
<!-- ปุ่มปิดเมนู (Close Button) -->
|
||||
<button
|
||||
class="absolute top-6 right-6 w-8 h-8 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 transition-colors"
|
||||
@click="mobileMenuOpen = false"
|
||||
|
|
@ -154,7 +153,7 @@ onUnmounted(() => {
|
|||
<q-icon name="close" size="20px" />
|
||||
</button>
|
||||
|
||||
<!-- Mobile Links -->
|
||||
<!-- ลิงก์สำหรับมือถือ (Mobile Links) -->
|
||||
<nav class="flex flex-col gap-2 mt-8">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
|
|
@ -210,14 +209,14 @@ onUnmounted(() => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Glassmorphism Effect for Scrolled Header */
|
||||
/* เอฟเฟกต์ Glassmorphism สำหรับ Header ตอนเลื่อนเมาส์ลง */
|
||||
.glass-nav {
|
||||
background: rgba(15, 23, 42, 0.95); /* Darker background for legibility */
|
||||
background: rgba(15, 23, 42, 0.95); /* พื้นหลังเข้มขึ้นเพื่อให้อ่านตัวหนังสือชัดเจน */
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Premium Primary Button Styling */
|
||||
/* สไตล์ปุ่มหลัก (Primary Button) แบบพรีเมียม */
|
||||
.btn-primary-premium {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
|
|
@ -236,7 +235,7 @@ onUnmounted(() => {
|
|||
box-shadow: 0 8px 20px -4px rgba(37, 99, 235, 0.5);
|
||||
}
|
||||
|
||||
/* Secondary Premium Button Styling */
|
||||
/* สไตล์ปุ่มดรอง (Secondary Button) แบบพรีเมียม */
|
||||
.btn-secondary-premium {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
|
|
@ -260,11 +259,11 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
/*
|
||||
Force Visibility Logic to bypass potential Tailwind Build issues
|
||||
Ensures Desktop and Mobile parts are strictly separated
|
||||
โลจิกบังคับการแสดงผล เพื่อแก้ปัญหาการคอมไฟล์ของ Tailwind
|
||||
ยืนยันว่าส่วน Desktop และ Mobile เลย์เอาต์แยกจากกันอย่างชัดเจน
|
||||
*/
|
||||
.desktop-nav {
|
||||
display: flex; /* Default to visible */
|
||||
display: flex; /* แสดงผลเป็นค่าเริ่มต้น */
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const handleNavigate = (path: string) => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Optional shadow for better separation */
|
||||
/* เงาด้านบนแบบบางๆ เพื่อแบ่งส่วนล่างให้ชัดเจนขึ้น (Optional shadow for better separation) */
|
||||
.shadow-up-1 {
|
||||
box-shadow: 0 -1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file PasswordChangeForm.vue
|
||||
* @description From for changing user password
|
||||
* @description ฟอร์มสำหรับเปลี่ยนรหัสผ่านของผู้ใช้ (From for changing user password)
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -130,7 +130,12 @@ const showConfirmPassword = ref(false);
|
|||
|
||||
<style scoped>
|
||||
.card-premium {
|
||||
@apply bg-white dark:bg-[#1e293b] border-slate-200 dark:border-white/5;
|
||||
background-color: white;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
:global(.dark) .card-premium {
|
||||
background-color: #1e293b;
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 1.5rem;
|
||||
border-width: 1px;
|
||||
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file ProfileEditForm.vue
|
||||
* @description From for editing user personal information
|
||||
* @description ฟอร์มสำหรับแก้ไขข้อมูลส่วนตัวของผู้ใช้ (Form for editing user personal information)
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -93,14 +93,14 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
|
|||
<div class="absolute inset-0 bg-black/40 rounded-2xl flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<q-icon name="camera_alt" class="text-white text-xl" />
|
||||
</div>
|
||||
<!-- Hidden Input -->
|
||||
<!-- ช่องเลือกไฟล์ที่ถูกซ่อนไว้ (Hidden Input) -->
|
||||
<input ref="fileInput" type="file" class="hidden" accept="image/*" @change="handleFileUpload" >
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-bold text-slate-900 dark:text-white mb-1">{{ $t('profile.yourAvatar') }}</div>
|
||||
|
||||
<!-- Buttons Row -->
|
||||
<!-- แถวปุ่มกด (Buttons Row) -->
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="modelValue.photoURL">
|
||||
<q-btn
|
||||
|
|
@ -124,7 +124,7 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add Limit Text -->
|
||||
<!-- ข้อความจำกัดขนาดไฟล์ (Add Limit Text) -->
|
||||
<div class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ $t('profile.uploadLimit') }}
|
||||
</div>
|
||||
|
|
@ -248,7 +248,12 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
|
|||
|
||||
<style scoped>
|
||||
.card-premium {
|
||||
@apply bg-white dark:bg-[#1e293b] border-slate-200 dark:border-white/5;
|
||||
background-color: white;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
:global(.dark) .card-premium {
|
||||
background-color: #1e293b;
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 1.5rem;
|
||||
border-width: 1px;
|
||||
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file UserAvatar.vue
|
||||
* @description คอมโพเนนต์แสดงรูปโปรไฟล์ผู้ใช้ หากไม่มีรูปจะแสดงตัวอักษรย่อของชื่อ
|
||||
*/
|
||||
const props = defineProps<{
|
||||
size?: number | string
|
||||
photoURL?: string
|
||||
|
|
@ -19,7 +23,7 @@ const avatarSize = computed(() => {
|
|||
const initials = computed(() => {
|
||||
const getFirstChar = (name?: string) => {
|
||||
if (!name) return ''
|
||||
// For Thai names, if the first char is a leading vowel (เ แ โ ใ ไ), skip it to get the consonant
|
||||
// สำหรับชื่อภาษาไทย ถ้าตัวอักษรแรกเป็นสระนำหน้า (เ แ โ ใ ไ) ให้ข้ามไปเอาพยัญชนะ
|
||||
const leadingVowels = ['เ', 'แ', 'โ', 'ใ', 'ไ']
|
||||
if (leadingVowels.includes(name.charAt(0)) && name.length > 1) {
|
||||
return name.charAt(1)
|
||||
|
|
@ -36,7 +40,7 @@ const handleImageError = () => {
|
|||
imageError.value = true
|
||||
}
|
||||
|
||||
// Watch for photoURL changes to reset error state
|
||||
// รีเซ็ตสถานะข้อผิดพลาดเมื่อรูปโปรไฟล์เปลี่ยน (Watch for photoURL changes to reset error state)
|
||||
watch(() => props.photoURL, () => {
|
||||
imageError.value = false
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* @file UserMenu.vue
|
||||
* @description User profile dropdown menu component using Quasar.
|
||||
* @description คอมโพเนนต์เมนู Dropdown ของโปรไฟล์ผู้ใช้ ใช้ Quasar
|
||||
*/
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
|
@ -12,7 +12,7 @@ const { currentUser, logout } = useAuth()
|
|||
const { t } = useI18n()
|
||||
const $q = useQuasar()
|
||||
|
||||
// Use centralized theme management
|
||||
// จัดการการเปลี่ยนธีมแบบรวมศูนย์ (Use centralized theme management)
|
||||
const { isDark, set } = useThemeMode()
|
||||
|
||||
const isHydrated = ref(false)
|
||||
|
|
@ -21,7 +21,7 @@ onMounted(() => {
|
|||
isHydrated.value = true
|
||||
})
|
||||
|
||||
// User Initials
|
||||
// ตัวอักษรย่อของผู้ใช้ (User Initials)
|
||||
const userInitials = computed(() => {
|
||||
if (!currentUser.value) return ''
|
||||
const f = currentUser.value.firstName?.charAt(0).toUpperCase() || 'U'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue