Website Structure
This commit is contained in:
parent
62812f2090
commit
71f0676a62
22365 changed files with 4265753 additions and 791 deletions
100
Frontend-Learner/components/user/UserAvatar.vue
Normal file
100
Frontend-Learner/components/user/UserAvatar.vue
Normal 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>
|
||||
210
Frontend-Learner/components/user/UserMenu.vue
Normal file
210
Frontend-Learner/components/user/UserMenu.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue