feat: Initialize core frontend application structure, including layouts, authentication pages, and common UI components.
This commit is contained in:
parent
ae84e7e879
commit
69eb60f901
16 changed files with 1178 additions and 1396 deletions
|
|
@ -2,7 +2,7 @@
|
|||
/**
|
||||
* @file AppHeader.vue
|
||||
* @description The main header for the authenticated application dashboard.
|
||||
* Includes sidebar toggle, branding, search functionality, and user menu.
|
||||
* Uses Quasar QToolbar.
|
||||
*/
|
||||
|
||||
defineProps<{
|
||||
|
|
@ -14,35 +14,68 @@ const emit = defineEmits<{
|
|||
/** Emitted when the hamburger menu is clicked */
|
||||
toggleSidebar: []
|
||||
}>()
|
||||
|
||||
const searchText = ref('')
|
||||
</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>
|
||||
<q-toolbar class="bg-white text-slate-800 border-b border-gray-100 h-16 px-4">
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="menu"
|
||||
@click="emit('toggleSidebar')"
|
||||
class="md:hidden mr-2"
|
||||
aria-label="Menu"
|
||||
/>
|
||||
|
||||
<!-- Branding -->
|
||||
<div class="flex items-center gap-2 cursor-pointer" @click="navigateTo('/dashboard')">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold">
|
||||
E
|
||||
</div>
|
||||
<span class="font-bold text-xl text-blue-600 hidden xs:block">e-Learning</span>
|
||||
</div>
|
||||
|
||||
<q-space />
|
||||
|
||||
<!-- Center Search (Optional) -->
|
||||
<div v-if="showSearch !== false" class="hidden md:block w-1/3 max-w-md mx-4">
|
||||
<q-input
|
||||
dense
|
||||
outlined
|
||||
rounded
|
||||
v-model="searchText"
|
||||
:placeholder="$t('menu.searchCourses')"
|
||||
class="bg-slate-50 search-input"
|
||||
bg-color="slate-50"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search" class="text-slate-400" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-space />
|
||||
|
||||
<!-- Right Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Search Bar (Optional) -->
|
||||
<div v-if="showSearch !== false" class="relative hidden-mobile" style="width: 300px;">
|
||||
<input
|
||||
type="text"
|
||||
class="input-field"
|
||||
:placeholder="$t('menu.searchCourses')"
|
||||
style="padding-left: 36px;"
|
||||
>
|
||||
<span style="position: absolute; left: 12px; top: 10px; color: var(--text-secondary);">🔍</span>
|
||||
</div>
|
||||
|
||||
<!-- Language Switcher (Left of Avatar) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Language Switcher -->
|
||||
<LanguageSwitcher />
|
||||
|
||||
<!-- User Profile Dropdown -->
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
</q-toolbar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-input :deep(.q-field__control) {
|
||||
border-radius: 9999px; /* Full rounded */
|
||||
}
|
||||
.search-input :deep(.q-field__control:before) {
|
||||
border-color: #e2e8f0; /* slate-200 */
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,107 +2,79 @@
|
|||
/**
|
||||
* @file AppSidebar.vue
|
||||
* @description Sidebar navigation for the authenticated dashboard.
|
||||
* Includes navigation links and responsive behaviors.
|
||||
* Uses Quasar QList for structure.
|
||||
*/
|
||||
|
||||
const route = useRoute();
|
||||
const { isAuthenticated } = useAuth(); // Optional if you need auth state
|
||||
const isSidebarOpen = defineModel<boolean>("open"); // Controlled by layout
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const navItems = computed(() => [
|
||||
{
|
||||
to: "/dashboard",
|
||||
label: t('sidebar.overview'),
|
||||
icon: "M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z",
|
||||
icon: "dashboard", // Using Material Icons names where possible or SVG paths
|
||||
isSvg: false
|
||||
},
|
||||
{
|
||||
to: "/dashboard/my-courses",
|
||||
label: t('sidebar.myCourses'),
|
||||
icon: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253",
|
||||
icon: "school",
|
||||
isSvg: false
|
||||
},
|
||||
{
|
||||
to: "/browse/discovery",
|
||||
label: t('sidebar.browseCourses'),
|
||||
icon: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
|
||||
icon: "explore",
|
||||
isSvg: false
|
||||
},
|
||||
{
|
||||
to: "/dashboard/announcements",
|
||||
label: t('sidebar.announcements'),
|
||||
icon: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9",
|
||||
icon: "campaign",
|
||||
isSvg: false
|
||||
},
|
||||
{
|
||||
to: "/dashboard/profile",
|
||||
label: t('sidebar.profile'),
|
||||
icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
|
||||
icon: "person",
|
||||
isSvg: false
|
||||
},
|
||||
]);
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === "/dashboard") return route.path === "/dashboard";
|
||||
return route.path.startsWith(path);
|
||||
};
|
||||
|
||||
const closeSidebar = () => {
|
||||
isSidebarOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Desktop & Mobile Sidebar -->
|
||||
<aside
|
||||
class="app-sidebar transition-all duration-300"
|
||||
:class="{ open: isSidebarOpen }"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Menu Items -->
|
||||
<div class="px-2 py-4 space-y-1">
|
||||
<NuxtLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.to) }"
|
||||
@click="closeSidebar"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="item.icon"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex flex-col h-full bg-white">
|
||||
<!-- Branding Area (Optional if not in Header) -->
|
||||
|
||||
<q-list padding class="text-slate-600 flex-grow">
|
||||
|
||||
<!-- Footer / Version -->
|
||||
<div class="mt-auto p-4 text-xs text-center opacity-50">
|
||||
<p>e-Learning v0.1.0</p>
|
||||
<p>© 2026</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<q-item
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
clickable
|
||||
v-ripple
|
||||
active-class="text-primary bg-blue-50 font-bold"
|
||||
class="rounded-r-full mr-2"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="item.icon" />
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>{{ item.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Mobile Overlay -->
|
||||
<div
|
||||
class="sidebar-overlay"
|
||||
:class="{ show: isSidebarOpen }"
|
||||
@click="closeSidebar"
|
||||
/>
|
||||
<!-- Footer / Version -->
|
||||
<div class="mt-auto p-4 text-xs text-center opacity-50 pb-8">
|
||||
<p>e-Learning v0.1.0</p>
|
||||
<p>© 2026</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Sidebar styles are mainly handled by global main.css (.app-sidebar) */
|
||||
/* This component structure ensures it hooks into the .app-shell grid correctly */
|
||||
/* Custom styles if needed */
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,198 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -82,13 +82,11 @@ onMounted(() => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Fixed header to stay on top */
|
||||
/* Header content */
|
||||
.landing-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Glassmorphism Effect for Scrolled Header */
|
||||
|
|
|
|||
|
|
@ -1,29 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', icon: '🏠', label: 'หน้าหลัก' },
|
||||
{ to: '/browse/discovery', icon: '🔍', label: 'รายการคอร์ส' },
|
||||
{ to: '/dashboard/my-courses', icon: '📚', label: 'คอร์สของฉัน' }
|
||||
{ to: '/dashboard', icon: 'dashboard', label: 'หน้าหลัก' },
|
||||
{ to: '/browse/discovery', icon: 'explore', label: 'รายการคอร์ส' },
|
||||
{ to: '/dashboard/my-courses', icon: 'school', label: 'คอร์สของฉัน' }
|
||||
]
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="mobile-nav">
|
||||
<NuxtLink
|
||||
<q-tabs
|
||||
indicator-color="primary"
|
||||
active-color="primary"
|
||||
class="bg-white text-slate-500 shadow-up-1"
|
||||
align="justify"
|
||||
dense
|
||||
>
|
||||
<q-route-tab
|
||||
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>
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
no-caps
|
||||
class="py-2"
|
||||
/>
|
||||
</q-tabs>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Optional shadow for better separation */
|
||||
.shadow-up-1 {
|
||||
box-shadow: 0 -1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,49 +1,47 @@
|
|||
<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
|
||||
* @description User profile dropdown menu component using Quasar.
|
||||
*/
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
|
||||
const { currentUser, logout } = useAuth()
|
||||
const { t } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const isDarkMode = ref(false)
|
||||
const menuRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// Sync Dark Mode state on mount
|
||||
// Sync Dark Mode state
|
||||
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)
|
||||
// Watch for dark mode changes
|
||||
watch(isDarkMode, (val) => {
|
||||
if (val) {
|
||||
document.documentElement.classList.add('dark')
|
||||
localStorage.setItem('theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
localStorage.setItem('theme', 'light')
|
||||
}
|
||||
})
|
||||
|
||||
// User Initials
|
||||
const userInitials = computed(() => {
|
||||
if (!currentUser.value) return ''
|
||||
const f = currentUser.value.firstName?.charAt(0).toUpperCase() || ''
|
||||
const f = currentUser.value.firstName?.charAt(0).toUpperCase() || 'U'
|
||||
const l = currentUser.value.lastName?.charAt(0).toUpperCase() || ''
|
||||
return f + l
|
||||
})
|
||||
|
||||
const userName = computed(() => {
|
||||
if (!currentUser.value) return ''
|
||||
if (!currentUser.value) return 'User'
|
||||
return `${currentUser.value.firstName} ${currentUser.value.lastName}`
|
||||
})
|
||||
|
||||
// Navigation menu definition
|
||||
// Navigation menu definition
|
||||
const menuItems = computed(() => [
|
||||
{ label: t('userMenu.home'), to: '/dashboard' },
|
||||
{ label: t('userMenu.courseList'), to: '/browse/discovery' },
|
||||
|
|
@ -51,166 +49,74 @@ const menuItems = computed(() => [
|
|||
{ label: t('userMenu.settings'), 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')
|
||||
localStorage.setItem('theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
localStorage.setItem('theme', 'light')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 class="q-pa-md">
|
||||
<q-btn round flat class="text-slate-700 dark:text-white">
|
||||
<q-avatar color="primary" text-color="white" size="40px" font-size="14px" class="font-bold shadow-md">
|
||||
{{ userInitials }}
|
||||
</q-avatar>
|
||||
|
||||
<q-menu
|
||||
anchor="bottom end"
|
||||
self="top end"
|
||||
class="rounded-2xl shadow-xl overflow-hidden border border-slate-100 dark:border-slate-700"
|
||||
:offset="[0, 10]"
|
||||
style="min-width: 240px; background-color: var(--bg-surface);"
|
||||
>
|
||||
<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
|
||||
}"
|
||||
<div class="bg-[#1e293b] text-white">
|
||||
<!-- Optional header if needed, but per design keeping it simple list -->
|
||||
</div>
|
||||
|
||||
<q-list class="bg-[#1e293b] text-white py-2">
|
||||
<q-item
|
||||
v-for="item in menuItems"
|
||||
:key="item.label"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="navigateTo(item.to)"
|
||||
class="hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label class="font-bold text-sm">{{ item.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item class="hover:bg-white/10 transition-colors">
|
||||
<q-item-section>
|
||||
<q-item-label class="font-bold text-sm">{{ $t('userMenu.darkMode') }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-toggle
|
||||
v-model="isDarkMode"
|
||||
color="blue"
|
||||
keep-color
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- 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>
|
||||
<q-separator class="bg-white/10 my-1" />
|
||||
|
||||
<!-- 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">{{ $t('userMenu.darkMode') }}</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"
|
||||
<div class="p-4">
|
||||
<q-btn
|
||||
unelevated
|
||||
class="w-full bg-red-500/10 text-red-400 hover:bg-red-500/20 font-bold rounded-lg"
|
||||
no-caps
|
||||
@click="handleLogout"
|
||||
>
|
||||
{{ $t('userMenu.logout') }}
|
||||
</button>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Backdrop to close menu -->
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-40"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
/* Override Quasar generic styles if necessary */
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue