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,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>

View file

@ -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;
}

View file

@ -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">

View file

@ -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"/>

View file

@ -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">

View file

@ -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);

View file

@ -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)"

View file

@ -1,4 +1,8 @@
<script setup lang="ts">
/**
* @file LoadingSpinner.vue
* @description ไอคอนหมนแสดงการโหลด (Loading Spinner Component) เหมาะสำหรบใชตรงจดเลกๆ หรอตอนโหลดหนาเว
*/
defineProps<{
size?: 'sm' | 'md' | 'lg'
text?: string

View file

@ -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>

View file

@ -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"

View file

@ -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">

View file

@ -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;

View file

@ -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>

View file

@ -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

View file

@ -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) {

View file

@ -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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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
})

View file

@ -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'