Website Structure

This commit is contained in:
supalerk-ar66 2026-01-13 10:46:40 +07:00
parent 62812f2090
commit 71f0676a62
22365 changed files with 4265753 additions and 791 deletions

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
/**
* @file AppHeader.vue
* @description The main header for the authenticated application dashboard.
* Includes sidebar toggle, branding, search functionality, and user menu.
*/
defineProps<{
/** Controls visibility of the search bar */
showSearch?: boolean
}>()
const emit = defineEmits<{
/** Emitted when the hamburger menu is clicked */
toggleSidebar: []
}>()
</script>
<template>
<header class="app-header transition-colors" style="background-color: var(--bg-surface); border-bottom: 1px solid var(--border-color);">
<!-- Branding & Toggle -->
<div class="flex items-center gap-2">
<NuxtLink to="/dashboard" style="font-weight: 800; color: var(--primary); font-size: 20px;">
e-Learning
</NuxtLink>
</div>
<!-- Right Actions -->
<div class="flex items-center gap-4">
<!-- Search Bar (Optional) -->
<div v-if="showSearch !== false" class="relative hidden-mobile" style="width: 300px;">
<input
type="text"
class="input-field"
placeholder="ค้นหาคอร์ส..."
style="padding-left: 36px;"
>
<span style="position: absolute; left: 12px; top: 10px; color: var(--text-secondary);">🔍</span>
</div>
<!-- User Profile Dropdown -->
<UserMenu />
</div>
</header>
</template>

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
/**
* @file FormInput.vue
* @description Reusable input component with label, error handling, and support for disabled/required states.
*/
defineProps<{
modelValue: string
label: string
type?: string
placeholder?: string
error?: string
required?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{
/** Update v-model value */
'update:modelValue': [value: string]
}>()
const updateValue = (event: Event) => {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>
<template>
<div class="form-group" :class="{ 'has-error': error }">
<label class="input-label">
{{ label }}
<span v-if="required" class="required-mark">*</span>
</label>
<input
:type="type || 'text'"
:value="modelValue"
class="input-field"
:class="{ 'input-error': error }"
:placeholder="placeholder"
:disabled="disabled"
@input="updateValue"
>
<span v-if="error" class="error-message">
<span class="error-icon">⚠</span>
{{ error }}
</span>
</div>
</template>
<style scoped>
.form-group {
margin-bottom: 16px;
}
.required-mark {
color: var(--error);
margin-left: 2px;
}
.input-error {
border-color: var(--error) !important;
background-color: rgba(239, 68, 68, 0.05);
}
.input-error:focus {
outline-color: var(--error) !important;
}
.error-message {
display: flex;
align-items: center;
gap: 6px;
color: var(--error);
font-size: 12px;
margin-top: 6px;
animation: shake 0.3s ease-in-out;
}
.error-icon {
font-size: 14px;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
</style>

View file

@ -0,0 +1,198 @@
<script setup lang="ts">
/**
* @file LandingFooter.vue
* @description The main footer for the public landing pages.
* Contains site links, social media icons, and app download buttons.
*/
</script>
<template>
<footer class="landing-footer">
<div class="container">
<!-- Main Footer Grid -->
<div class="footer-grid">
<!-- Column 1: Brand & Social Media -->
<div class="footer-brand">
<div class="flex items-center gap-2 mb-4">
<div class="logo-box">E</div>
<span class="font-bold text-xl" style="color: white;">E-Learning System</span>
</div>
<p class="text-sm text-slate-400 mb-6">แพลตฟอร์มการเรียนออนไลน์สำหรับทักษแห่งอนาคต</p>
<div class="flex gap-4 social-icons">
<!-- Placeholder icons -->
<a href="#" class="social-icon">f</a>
<a href="#" class="social-icon">IG</a>
<a href="#" class="social-icon">Y</a>
</div>
</div>
<!-- Column 2: Services Links -->
<div class="footer-column">
<h4 class="font-bold mb-4" style="color: white;">บริการทั้งหมด</h4>
<ul class="footer-links">
<li><a href="#">คอร์สเรียนออนไลน์</a></li>
<li><a href="#">หลักสูตร Onsite</a></li>
<li><a href="#">หลักสูตร Class</a></li>
<li><a href="#">สำหรับองค์กร</a></li>
</ul>
</div>
<!-- Column 3: About Links -->
<div class="footer-column">
<h4 class="font-bold mb-4" style="color: white;">เกี่ยวกับเรา</h4>
<ul class="footer-links">
<li><a href="#">แพ็กเกจรายปี</a></li>
<li><a href="#">เส้นทางการเรียน</a></li>
<li><a href="#">วัดระดับทักษะ <span class="badge-beta">Beta</span></a></li>
<li><a href="#">บทความ</a></li>
<li><a href="#">คำถามที่พบบ่อย</a></li>
</ul>
</div>
<!-- Column 4: Join Us Links -->
<div class="footer-column">
<h4 class="font-bold mb-4" style="color: white;">ร่วมงานกับเรา</h4>
<ul class="footer-links">
<li><a href="#">สมัครงาน</a></li>
<li><a href="#">สมัครเป็น Affiliate</a></li>
<li><a href="#">สมัครเป็นผู้สอน</a></li>
<li><a href="#">เกี่ยวกับเรา</a></li>
<li><a href="#">ติดต่อเรา</a></li>
</ul>
</div>
<!-- Column 5: App Download & Contact -->
<div class="footer-column">
<h4 class="font-bold mb-4" style="color: white;">ดาวน์โหลดแอปพลิเคชัน</h4>
<div class="flex flex-col gap-2">
<button class="app-btn">
<span>🍎</span> App Store
</button>
<button class="app-btn">
<span>▶️</span> Google Play
</button>
</div>
<h4 class="font-bold mt-6 mb-4" style="color: white;">ปรึกษาการเรียน</h4>
<button class="btn btn-success w-full">
<span>💬</span> เพิ่มเพื่อน
</button>
</div>
</div>
<!-- Footer Bottom: Copyright & Legal -->
<div class="footer-bottom">
<p class="text-xs text-slate-400">© Copyright 2019-2026 LIKE ME X CO.,LTD All rights reserved.</p>
<div class="flex gap-4 text-xs text-slate-400">
<a href="#">ข้อตกลงการใช้บริการ</a>
<span>|</span>
<a href="#">นโยบายความเป็นส่วนตัว</a>
<span>|</span>
<a href="#">นโยบายการคืนเงิน</a>
</div>
</div>
</div>
</footer>
</template>
<style scoped>
/* Main Footer Structure */
.landing-footer {
background-color: #080b14; /* Deep Navy Background */
padding: 60px 0 20px;
border-top: 1px solid rgba(255, 255, 255, 0.05); /* Subtle border */
margin-top: auto;
color: #94a3b8;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
/* 5-Column Grid Layout */
.footer-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr; /* First column is wider for branding */
gap: 40px;
margin-bottom: 60px;
}
.logo-box {
width: 32px;
height: 32px;
background: #eff6ff;
color: #3b82f6;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
}
.footer-links li {
margin-bottom: 12px;
}
.footer-links a {
color: #94a3b8;
font-size: 14px;
transition: color 0.2s;
}
.footer-links a:hover {
color: #3b82f6;
}
.badge-beta {
background: #fee2e2;
color: #ef4444;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
margin-left: 4px;
}
.app-btn {
background: #000;
color: white;
border: none;
border-radius: 8px;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
cursor: pointer;
}
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding-top: 20px;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
/* Responsive Breakpoints */
@media (max-width: 1024px) {
.footer-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.footer-grid {
grid-template-columns: 1fr;
gap: 32px;
}
.footer-bottom {
flex-direction: column;
align-items: center;
text-align: center;
}
}
</style>

View file

@ -0,0 +1,131 @@
<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.
*/
// Track scrolling state to adjust header styling
const isScrolled = ref(false)
const { isAuthenticated } = useAuth()
onMounted(() => {
// Add scroll listener to toggle 'isScrolled' class
window.addEventListener('scroll', () => {
isScrolled.value = window.scrollY > 20
})
})
</script>
<template>
<!--
Header Container
- Transitions between transparent and glass effect based on scroll.
-->
<header
class="landing-header transition-all duration-300"
:class="[isScrolled ? 'h-16 glass-nav shadow-lg' : 'h-24 bg-transparent']"
>
<div class="container h-full flex items-center justify-between">
<!--
Left Section: Logo & Desktop Navigation
-->
<div class="flex items-center gap-12">
<!-- Logo -->
<NuxtLink to="/" class="flex items-center gap-3 group">
<div class="logo-box bg-blue-600 text-white font-black rounded-xl w-10 h-10 flex items-center justify-center shadow-lg shadow-blue-600/30 group-hover:scale-110 transition-transform">
E
</div>
<div class="flex flex-col">
<span class="font-black text-lg leading-none tracking-tight text-white group-hover:text-blue-400 transition-colors">E-Learning</span>
<span class="text-[10px] font-bold text-slate-500 uppercase tracking-[0.2em] leading-none mt-1">Platform</span>
</div>
</NuxtLink>
<!-- Desktop Links -->
<nav class="hidden md:block">
<ul class="flex items-center gap-8 text-sm font-bold">
<li>
<NuxtLink to="/browse" class="text-slate-400 hover:text-white transition-colors relative group">
คอร์สทั้งหมด
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
</NuxtLink>
</li>
<li>
<NuxtLink to="/browse/discovery" class="text-slate-400 hover:text-white transition-colors relative group">
ค้นพบ
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
</NuxtLink>
</li>
</ul>
</nav>
</div>
<!--
Right Section: Action Buttons (Login/Register or Dashboard)
-->
<div class="flex items-center gap-4">
<template v-if="!isAuthenticated">
<NuxtLink to="/auth/login" class="text-sm font-bold text-slate-300 hover:text-white px-4 py-2 transition-colors">เข้าสู่ระบบ</NuxtLink>
<NuxtLink to="/auth/register" class="btn-primary-premium shadow-lg shadow-blue-600/20">
เริ่มต้นใช้งาน
</NuxtLink>
</template>
<template v-else>
<NuxtLink to="/dashboard" class="btn-primary-premium shadow-lg shadow-blue-600/20">
เข้าสู่หน้าจัดการเรียน
</NuxtLink>
</template>
</div>
</div>
</header>
</template>
<style scoped>
/* Fixed header to stay on top */
.landing-header {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 100;
}
/* Glassmorphism Effect for Scrolled Header */
.glass-nav {
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.container {
max-width: 1440px;
margin: 0 auto;
padding: 0 24px;
}
/* Premium Primary Button Styling */
.btn-primary-premium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.75rem;
font-size: 0.875rem;
font-weight: 800;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
display: flex;
align-items: center;
justify-content: center;
}
.btn-primary-premium:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px -4px rgba(37, 99, 235, 0.5);
}
@media (max-width: 768px) {
.container {
padding: 0 16px;
}
}
</style>

View file

@ -0,0 +1,108 @@
<script setup lang="ts">
defineProps<{
type?: 'text' | 'avatar' | 'card' | 'button'
width?: string
height?: string
count?: number
}>()
</script>
<template>
<div class="skeleton-wrapper">
<template v-if="type === 'card'">
<div v-for="i in (count || 1)" :key="i" class="skeleton-card">
<div class="skeleton skeleton-image"/>
<div class="skeleton-content">
<div class="skeleton skeleton-title"/>
<div class="skeleton skeleton-text"/>
<div class="skeleton skeleton-text short"/>
</div>
</div>
</template>
<template v-else-if="type === 'avatar'">
<div class="skeleton skeleton-avatar"/>
</template>
<template v-else-if="type === 'button'">
<div class="skeleton skeleton-button" :style="{ width: width || '120px' }"/>
</template>
<template v-else>
<div
v-for="i in (count || 1)"
:key="i"
class="skeleton skeleton-text"
:style="{ width: width || '100%', height: height || '16px' }"
/>
</template>
</div>
</template>
<style scoped>
.skeleton-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
}
.skeleton {
background: linear-gradient(90deg, var(--neutral-100) 25%, var(--neutral-200) 50%, var(--neutral-100) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
}
.skeleton-card {
background: var(--bg-surface);
border-radius: var(--radius-xl);
border: 1px solid var(--border-color);
overflow: hidden;
}
.skeleton-image {
height: 160px;
border-radius: 0;
}
.skeleton-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.skeleton-title {
height: 20px;
width: 80%;
}
.skeleton-text {
height: 14px;
width: 100%;
}
.skeleton-text.short {
width: 60%;
}
.skeleton-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
.skeleton-button {
height: 40px;
border-radius: 8px;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
</style>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
defineProps<{
size?: 'sm' | 'md' | 'lg'
text?: string
fullPage?: boolean
}>()
</script>
<template>
<div :class="['loading-container', { 'loading-fullpage': fullPage }]">
<div :class="['spinner', size || 'md']"/>
<span v-if="text" class="loading-text">{{ text }}</span>
</div>
</template>
<style scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 20px;
}
.loading-fullpage {
position: fixed;
inset: 0;
background: rgba(255, 255, 255, 0.9);
z-index: 9999;
}
.spinner {
border: 3px solid var(--neutral-200);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner.sm {
width: 20px;
height: 20px;
border-width: 2px;
}
.spinner.md {
width: 36px;
height: 36px;
border-width: 3px;
}
.spinner.lg {
width: 56px;
height: 56px;
border-width: 4px;
}
.loading-text {
color: var(--text-secondary);
font-size: 14px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
const route = useRoute()
const navItems = [
{ to: '/dashboard', icon: '🏠', label: 'หน้าหลัก' },
{ to: '/browse/discovery', icon: '🔍', label: 'รายการคอร์ส' },
{ to: '/dashboard/my-courses', icon: '📚', label: 'คอร์สของฉัน' }
]
const isActive = (path: string) => {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
</script>
<template>
<nav class="mobile-nav">
<NuxtLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="mobile-nav-item"
:class="{ active: isActive(item.to) }"
>
<span>{{ item.icon }}</span>
<span>{{ item.label }}</span>
</NuxtLink>
</nav>
</template>

View file

@ -0,0 +1,237 @@
<script setup lang="ts">
/**
* @file CourseCard.vue
* @description Card component for displaying course information.
* Supports various states: default, in-progress, completed.
* Handles action events (view, continue, certificate).
*/
interface CourseCardProps {
/** Course Title */
title: string
/** Difficulty Level (Beginner, Intermediate, etc.) */
level?: string
/** Visual type for level badge (color coding) */
levelType?: 'neutral' | 'warning' | 'success'
/** Course Price (e.g., 'Free', '฿990') */
price?: string
/** Short description */
description?: string
/** Rating score (e.g., '4.8') */
rating?: string
/** Number of lessons */
lessons?: string
/** Learning progress percentage (0-100) */
progress?: number
/** Whether the course is completed */
completed?: boolean
/** Show 'View Details' button */
showViewDetails?: boolean
/** Show 'Continue Learning' button */
showContinue?: boolean
/** Show 'Download Certificate' button (if completed) */
showCertificate?: boolean
/** Show 'Study Again' button (if completed) */
showStudyAgain?: boolean
/** URL for course thumbnail image */
image?: string
}
defineProps<CourseCardProps>()
const emit = defineEmits<{
viewDetails: []
continue: []
viewCertificate: []
}>()
</script>
<template>
<div class="card course-card group">
<!-- Thumbnail -->
<div class="thumbnail-wrapper relative h-44 overflow-hidden rounded-t-[1.5rem]">
<img
:src="image || 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=400&q=80'"
:alt="title"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
>
<div v-if="completed" class="absolute inset-0 bg-emerald-600/60 backdrop-blur-[2px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span class="text-white bg-emerald-500 w-12 h-12 rounded-full flex items-center justify-center shadow-lg transform scale-0 group-hover:scale-100 transition-transform duration-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="20 20 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</span>
</div>
<!-- Badges overlay -->
<div class="absolute top-4 left-4 flex gap-2">
<span v-if="level" :class="`status-pill status-${levelType || 'neutral'} shadow-sm text-[9px]`">
{{ level }}
</span>
</div>
<div v-if="price" class="absolute top-4 right-4 glass px-3 py-1 rounded-full text-xs font-black text-white shadow-sm">
{{ price }}
</div>
</div>
<!-- Content -->
<div class="p-6 flex flex-col flex-1">
<!-- Title -->
<h3 class="font-black text-lg mb-2 line-clamp-1 text-slate-900 dark:text-white group-hover:text-blue-400 transition-colors">{{ title }}</h3>
<!-- Description -->
<div v-if="description" class="text-sm mb-6 line-clamp-2 leading-relaxed text-slate-600 dark:text-slate-400">{{ description }}</div>
<!-- Rating & Lessons -->
<div v-if="rating || lessons" class="flex items-center gap-4 text-[11px] font-bold mb-6 uppercase tracking-wider text-slate-500 dark:text-slate-400">
<span v-if="rating" class="flex items-center gap-1"><span class="text-amber-400 text-sm">★</span> {{ rating }}</span>
<span v-if="lessons" class="flex items-center gap-1"><span class="text-blue-400">📚</span> {{ lessons }} บทเรียน</span>
</div>
<!-- Progress Bar -->
<div v-if="progress !== undefined && !completed" class="mb-8 p-3 rounded-2xl" style="background-color: rgba(148, 163, 184, 0.1);">
<div class="flex justify-between items-center text-[10px] font-black uppercase tracking-widest mb-1.5">
<span style="color: var(--text-secondary);">Progress</span>
<span class="text-blue-600">{{ progress }}%</span>
</div>
<div class="w-full h-1.5 bg-slate-300 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full bg-blue-600 shadow-[0_0_10px_rgba(37,99,235,0.4)] rounded-full transition-all duration-1000" :style="{ width: `${progress}%` }"/>
</div>
</div>
<!-- Completed Badge -->
<div v-if="completed" class="mb-6">
<span class="status-pill status-success text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2">
<span class="text-sm">✓</span> เรียนจบเรียบร้อย
</span>
</div>
<!-- Actions -->
<button v-if="showViewDetails" class="btn-premium-secondary w-full mt-auto" @click="emit('viewDetails')">
ดูรายละเอียด
</button>
<NuxtLink v-if="showContinue" to="/classroom/learning" class="btn-premium-primary w-full mt-auto shadow-lg shadow-blue-600/20">
เรียนต่อทันที
</NuxtLink>
<div v-if="completed && (showCertificate || showStudyAgain)" class="flex flex-col gap-2 mt-auto">
<NuxtLink v-if="showStudyAgain" to="/classroom/learning" class="btn-premium-secondary w-full">
ทบทวนบทเรียน
</NuxtLink>
<button v-if="showCertificate" class="btn-premium-success w-full shadow-lg shadow-emerald-600/20" @click="emit('viewCertificate')">
ดาวน์โหลดประกาศนียบัตร
</button>
</div>
</div>
</div>
</template>
<style scoped>
.course-card {
padding: 0;
border-radius: 2rem;
overflow: hidden;
display: flex;
flex-direction: column;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
background: var(--bg-surface);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-md);
}
.course-card:hover {
transform: translateY(-8px);
box-shadow: var(--shadow-xl);
}
/* Remove explicit dark mode block as default variables handle it */
/* But keep specific tweaks if needed */
.btn-premium-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
padding: 0.875rem;
border-radius: 1rem;
font-weight: 800;
font-size: 0.875rem;
text-align: center;
transition: all 0.3s ease;
}
.btn-premium-primary:hover {
filter: brightness(110%);
transform: translateY(-2px);
}
.btn-premium-secondary {
background: transparent;
color: var(--text-main);
padding: 0.875rem;
border-radius: 1rem;
font-weight: 800;
font-size: 0.875rem;
border: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.btn-premium-secondary:hover {
background: var(--bg-body);
border-color: #cbd5e1;
}
:global(.dark) .btn-premium-secondary {
background: rgba(255, 255, 255, 0.05);
color: #e2e8f0;
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .btn-premium-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.btn-premium-success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
padding: 0.875rem;
border-radius: 1rem;
font-weight: 800;
font-size: 0.875rem;
transition: all 0.3s ease;
}
.glass {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
border: 1px solid rgba(229, 231, 235, 0.5);
}
:global(.dark) .glass {
background: rgba(15, 23, 42, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.status-pill {
@apply px-3 py-1 rounded-full font-black uppercase tracking-wider;
}
.status-success {
@apply bg-emerald-100 text-emerald-700;
}
.status-warning {
@apply bg-amber-100 text-amber-700;
}
.status-neutral {
@apply bg-slate-100 text-slate-700;
}
:global(.dark) .status-success {
@apply bg-emerald-900/40 text-emerald-300;
}
:global(.dark) .status-warning {
@apply bg-amber-900/40 text-amber-300;
}
:global(.dark) .status-neutral {
@apply bg-slate-700 text-slate-300;
}
</style>

View file

@ -0,0 +1,100 @@
<script setup lang="ts">
const props = defineProps<{
size?: number | string
photoURL?: string
firstName?: string
lastName?: string
}>()
const imageError = ref(false)
const avatarSize = computed(() => {
const s = props.size || 32
if (typeof s === 'number') return `${s}px`
// If it's a string consisting only of digits, append px
if (/^\d+$/.test(s)) return `${s}px`
return s
})
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)
}
return name.charAt(0)
}
const f = getFirstChar(props.firstName)
const l = getFirstChar(props.lastName)
return (f + l).toUpperCase()
})
const handleImageError = () => {
imageError.value = true
}
// Watch for photoURL changes to reset error state
watch(() => props.photoURL, () => {
imageError.value = false
})
</script>
<template>
<div
class="user-avatar"
:style="{ width: avatarSize, height: avatarSize }"
>
<img
v-if="photoURL && !imageError"
:src="photoURL"
class="avatar-img"
@error="handleImageError"
>
<div v-else class="avatar-initials">
{{ initials }}
</div>
</div>
</template>
<style scoped>
.user-avatar {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: var(--neutral-200);
color: var(--neutral-600);
font-weight: 700;
font-size: 13px;
flex-shrink: 0;
border: 1px solid var(--border-color);
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-initials {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #3b82f6; /* Blue-500 to match theme */
color: #ffffff;
user-select: none;
font-size: 13px;
letter-spacing: 0.05em;
}
/* Specific styling for AppHeader integration */
:deep(.avatar-initials) {
text-transform: uppercase;
}
</style>

View file

@ -0,0 +1,210 @@
<script setup lang="ts">
/**
* @file UserMenu.vue
* @description User profile dropdown menu component.
* Features:
* - Displays user initials and info
* - Navigation links
* - Dark mode toggle
* - Logout functionality
* - Responsive positioning using Teleport
*/
import { ref, computed, onMounted } from 'vue'
import { useAuth } from '~/composables/useAuth'
const { currentUser, logout } = useAuth()
const isOpen = ref(false)
const isDarkMode = ref(false)
const menuRef = ref<HTMLDivElement | null>(null)
// Sync Dark Mode state on mount
onMounted(() => {
// Should default to light, so we ensure state matches.
if (document.documentElement.classList.contains('dark')) {
isDarkMode.value = true
}
})
// Position calculation for the dropdown
const menuPosition = ref({ top: '0px', right: '0px' })
// Computed property to display user initials (e.g. "JD" for John Doe)
const userInitials = computed(() => {
const f = currentUser.value.firstName?.charAt(0).toUpperCase() || ''
const l = currentUser.value.lastName?.charAt(0).toUpperCase() || ''
return f + l
})
const userName = computed(() => {
return `${currentUser.value.firstName} ${currentUser.value.lastName}`
})
// Navigation menu definition
const menuItems = [
{ label: 'หน้าหลัก', to: '/dashboard' },
{ label: 'รายการคอร์ส', to: '/browse/discovery' },
{ label: 'คอร์สของฉัน', to: '/dashboard/my-courses' },
{ label: 'ตั้งค่าบัญชี', to: '/dashboard/profile' }
]
const handleNavigate = (path: string) => {
navigateTo(path)
isOpen.value = false
}
const handleLogout = async () => {
await logout()
}
/**
* Toggles dark mode on the document element.
*/
const toggleDarkMode = () => {
isDarkMode.value = !isDarkMode.value
if (isDarkMode.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
/**
* Calculates and updates the menu position relative to the button.
*/
const updateMenuPosition = () => {
if (menuRef.value) {
const rect = menuRef.value.getBoundingClientRect()
menuPosition.value = {
top: `${rect.bottom + 8}px`,
right: `${window.innerWidth - rect.right}px`
}
}
}
const toggleMenu = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
updateMenuPosition()
}
}
// Event listeners for closing menu on click outside and resizing
onMounted(() => {
const handleClickOutside = (e: MouseEvent) => {
if (isOpen.value && menuRef.value && !menuRef.value.contains(e.target as Node)) {
isOpen.value = false
}
}
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
}
})
onMounted(() => {
const handleResize = () => {
if (isOpen.value) {
updateMenuPosition()
}
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
</script>
<template>
<div ref="menuRef" class="relative">
<!-- Avatar Button -->
<button
class="flex items-center justify-center w-10 h-10 rounded-full bg-blue-500 text-white font-bold text-sm hover:bg-blue-600 transition-colors cursor-pointer ring-2 ring-blue-500/30"
:title="userName"
@click="toggleMenu"
>
{{ userInitials }}
</button>
<!-- Dropdown Menu - Teleport to body to avoid overflow-hidden -->
<Teleport to="body">
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="isOpen"
class="fixed rounded-2xl shadow-lg dark:shadow-2xl z-50 w-64 border overflow-hidden transition-colors"
style="background-color: var(--bg-surface); border-color: rgba(255,255,255,0.1);"
:style="{
top: menuPosition.top,
right: menuPosition.right
}"
>
<!-- Menu Items -->
<nav class="py-2">
<button
v-for="item in menuItems"
:key="item.label"
class="w-full px-6 py-3 text-left transition-colors text-sm font-medium hover:bg-slate-100 dark:hover:bg-white/10 text-slate-800 dark:text-white"
@click="handleNavigate(item.to)"
>
{{ item.label }}
</button>
</nav>
<!-- Dark Mode Toggle -->
<div class="px-6 py-3 border-slate-200 dark:border-slate-700">
<button
class="w-full flex items-center justify-between py-2"
@click="toggleDarkMode"
>
<span class="text-sm font-medium text-slate-800 dark:text-white">โหมดกลางคืน</span>
<div
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
isDarkMode ? 'bg-blue-600' : 'bg-slate-300'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
isDarkMode ? 'translate-x-6' : 'translate-x-1'
]"
/>
</div>
</button>
</div>
<!-- Logout Button -->
<div class="px-6 py-3 border-t border-slate-200 dark:border-slate-700">
<button
class="w-full px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40 rounded-lg font-medium transition-colors text-sm"
@click="handleLogout"
>
ออกจากระบบ
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Backdrop to close menu -->
<div
v-if="isOpen"
class="fixed inset-0 z-40"
@click="isOpen = false"
/>
</div>
</template>
<style scoped>
button {
transition: all 0.2s ease;
}
</style>