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
|
* @file AppHeader.vue
|
||||||
* @description The main header for the authenticated application dashboard.
|
* @description The main header for the authenticated application dashboard.
|
||||||
* Includes sidebar toggle, branding, search functionality, and user menu.
|
* Uses Quasar QToolbar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
@ -14,35 +14,68 @@ const emit = defineEmits<{
|
||||||
/** Emitted when the hamburger menu is clicked */
|
/** Emitted when the hamburger menu is clicked */
|
||||||
toggleSidebar: []
|
toggleSidebar: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const searchText = ref('')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="app-header transition-colors" style="background-color: var(--bg-surface); border-bottom: 1px solid var(--border-color);">
|
<q-toolbar class="bg-white text-slate-800 border-b border-gray-100 h-16 px-4">
|
||||||
<!-- Branding & Toggle -->
|
<!-- Mobile Menu Toggle -->
|
||||||
<div class="flex items-center gap-2">
|
<q-btn
|
||||||
<NuxtLink to="/dashboard" style="font-weight: 800; color: var(--primary); font-size: 20px;">
|
flat
|
||||||
e-Learning
|
round
|
||||||
</NuxtLink>
|
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>
|
</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 -->
|
<!-- Right Actions -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Search Bar (Optional) -->
|
<!-- Language Switcher -->
|
||||||
<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) -->
|
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|
||||||
<!-- User Profile Dropdown -->
|
<!-- User Profile Dropdown -->
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</q-toolbar>
|
||||||
</template>
|
</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
|
* @file AppSidebar.vue
|
||||||
* @description Sidebar navigation for the authenticated dashboard.
|
* @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 { t } = useI18n()
|
||||||
|
|
||||||
const navItems = computed(() => [
|
const navItems = computed(() => [
|
||||||
{
|
{
|
||||||
to: "/dashboard",
|
to: "/dashboard",
|
||||||
label: t('sidebar.overview'),
|
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",
|
to: "/dashboard/my-courses",
|
||||||
label: t('sidebar.myCourses'),
|
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",
|
to: "/browse/discovery",
|
||||||
label: t('sidebar.browseCourses'),
|
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",
|
to: "/dashboard/announcements",
|
||||||
label: t('sidebar.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",
|
to: "/dashboard/profile",
|
||||||
label: t('sidebar.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col h-full bg-white">
|
||||||
<!-- Desktop & Mobile Sidebar -->
|
<!-- Branding Area (Optional if not in Header) -->
|
||||||
<aside
|
|
||||||
class="app-sidebar transition-all duration-300"
|
<q-list padding class="text-slate-600 flex-grow">
|
||||||
: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>
|
|
||||||
|
|
||||||
<!-- Footer / Version -->
|
|
||||||
<div class="mt-auto p-4 text-xs text-center opacity-50">
|
<q-item
|
||||||
<p>e-Learning v0.1.0</p>
|
v-for="item in navItems"
|
||||||
<p>© 2026</p>
|
:key="item.to"
|
||||||
</div>
|
:to="item.to"
|
||||||
</div>
|
clickable
|
||||||
</aside>
|
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 -->
|
<!-- Footer / Version -->
|
||||||
<div
|
<div class="mt-auto p-4 text-xs text-center opacity-50 pb-8">
|
||||||
class="sidebar-overlay"
|
<p>e-Learning v0.1.0</p>
|
||||||
:class="{ show: isSidebarOpen }"
|
<p>© 2026</p>
|
||||||
@click="closeSidebar"
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Sidebar styles are mainly handled by global main.css (.app-sidebar) */
|
/* Custom styles if needed */
|
||||||
/* This component structure ensures it hooks into the .app-shell grid correctly */
|
|
||||||
</style>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Fixed header to stay on top */
|
/* Header content */
|
||||||
.landing-header {
|
.landing-header {
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glassmorphism Effect for Scrolled Header */
|
/* Glassmorphism Effect for Scrolled Header */
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,34 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/dashboard', icon: '🏠', label: 'หน้าหลัก' },
|
{ to: '/dashboard', icon: 'dashboard', label: 'หน้าหลัก' },
|
||||||
{ to: '/browse/discovery', icon: '🔍', label: 'รายการคอร์ส' },
|
{ to: '/browse/discovery', icon: 'explore', label: 'รายการคอร์ส' },
|
||||||
{ to: '/dashboard/my-courses', icon: '📚', label: 'คอร์สของฉัน' }
|
{ to: '/dashboard/my-courses', icon: 'school', label: 'คอร์สของฉัน' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
|
||||||
if (path === '/') return route.path === '/'
|
|
||||||
return route.path.startsWith(path)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="mobile-nav">
|
<q-tabs
|
||||||
<NuxtLink
|
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"
|
v-for="item in navItems"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
class="mobile-nav-item"
|
:icon="item.icon"
|
||||||
:class="{ active: isActive(item.to) }"
|
:label="item.label"
|
||||||
>
|
no-caps
|
||||||
<span>{{ item.icon }}</span>
|
class="py-2"
|
||||||
<span>{{ item.label }}</span>
|
/>
|
||||||
</NuxtLink>
|
</q-tabs>
|
||||||
</nav>
|
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* @file UserMenu.vue
|
* @file UserMenu.vue
|
||||||
* @description User profile dropdown menu component.
|
* @description User profile dropdown menu component using Quasar.
|
||||||
* Features:
|
|
||||||
* - Displays user initials and info
|
|
||||||
* - Navigation links
|
|
||||||
* - Dark mode toggle
|
|
||||||
* - Logout functionality
|
|
||||||
* - Responsive positioning using Teleport
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useAuth } from '~/composables/useAuth'
|
import { useAuth } from '~/composables/useAuth'
|
||||||
|
|
||||||
const { currentUser, logout } = useAuth()
|
const { currentUser, logout } = useAuth()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const isOpen = ref(false)
|
|
||||||
const isDarkMode = ref(false)
|
const isDarkMode = ref(false)
|
||||||
const menuRef = ref<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
// Sync Dark Mode state on mount
|
// Sync Dark Mode state
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Should default to light, so we ensure state matches.
|
|
||||||
if (document.documentElement.classList.contains('dark')) {
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
isDarkMode.value = true
|
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(() => {
|
const userInitials = computed(() => {
|
||||||
if (!currentUser.value) return ''
|
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() || ''
|
const l = currentUser.value.lastName?.charAt(0).toUpperCase() || ''
|
||||||
return f + l
|
return f + l
|
||||||
})
|
})
|
||||||
|
|
||||||
const userName = computed(() => {
|
const userName = computed(() => {
|
||||||
if (!currentUser.value) return ''
|
if (!currentUser.value) return 'User'
|
||||||
return `${currentUser.value.firstName} ${currentUser.value.lastName}`
|
return `${currentUser.value.firstName} ${currentUser.value.lastName}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Navigation menu definition
|
|
||||||
// Navigation menu definition
|
|
||||||
const menuItems = computed(() => [
|
const menuItems = computed(() => [
|
||||||
{ label: t('userMenu.home'), to: '/dashboard' },
|
{ label: t('userMenu.home'), to: '/dashboard' },
|
||||||
{ label: t('userMenu.courseList'), to: '/browse/discovery' },
|
{ label: t('userMenu.courseList'), to: '/browse/discovery' },
|
||||||
|
|
@ -51,166 +49,74 @@ const menuItems = computed(() => [
|
||||||
{ label: t('userMenu.settings'), to: '/dashboard/profile' }
|
{ label: t('userMenu.settings'), to: '/dashboard/profile' }
|
||||||
])
|
])
|
||||||
|
|
||||||
const handleNavigate = (path: string) => {
|
|
||||||
navigateTo(path)
|
|
||||||
isOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="menuRef" class="relative">
|
<div class="q-pa-md">
|
||||||
<!-- Avatar Button -->
|
<q-btn round flat class="text-slate-700 dark:text-white">
|
||||||
<button
|
<q-avatar color="primary" text-color="white" size="40px" font-size="14px" class="font-bold shadow-md">
|
||||||
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"
|
{{ userInitials }}
|
||||||
:title="userName"
|
</q-avatar>
|
||||||
@click="toggleMenu"
|
|
||||||
>
|
<q-menu
|
||||||
{{ userInitials }}
|
anchor="bottom end"
|
||||||
</button>
|
self="top end"
|
||||||
|
class="rounded-2xl shadow-xl overflow-hidden border border-slate-100 dark:border-slate-700"
|
||||||
<!-- Dropdown Menu - Teleport to body to avoid overflow-hidden -->
|
:offset="[0, 10]"
|
||||||
<Teleport to="body">
|
style="min-width: 240px; background-color: var(--bg-surface);"
|
||||||
<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
|
<div class="bg-[#1e293b] text-white">
|
||||||
v-if="isOpen"
|
<!-- Optional header if needed, but per design keeping it simple list -->
|
||||||
class="fixed rounded-2xl shadow-lg dark:shadow-2xl z-50 w-64 border overflow-hidden transition-colors"
|
</div>
|
||||||
style="background-color: var(--bg-surface); border-color: rgba(255,255,255,0.1);"
|
|
||||||
:style="{
|
<q-list class="bg-[#1e293b] text-white py-2">
|
||||||
top: menuPosition.top,
|
<q-item
|
||||||
right: menuPosition.right
|
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 -->
|
<q-separator class="bg-white/10 my-1" />
|
||||||
<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="p-4">
|
||||||
<div class="px-6 py-3 border-slate-200 dark:border-slate-700">
|
<q-btn
|
||||||
<button
|
unelevated
|
||||||
class="w-full flex items-center justify-between py-2"
|
class="w-full bg-red-500/10 text-red-400 hover:bg-red-500/20 font-bold rounded-lg"
|
||||||
@click="toggleDarkMode"
|
no-caps
|
||||||
>
|
|
||||||
<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"
|
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
>
|
>
|
||||||
{{ $t('userMenu.logout') }}
|
{{ $t('userMenu.logout') }}
|
||||||
</button>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</q-list>
|
||||||
</Transition>
|
</q-menu>
|
||||||
</Teleport>
|
</q-btn>
|
||||||
|
|
||||||
<!-- Backdrop to close menu -->
|
|
||||||
<div
|
|
||||||
v-if="isOpen"
|
|
||||||
class="fixed inset-0 z-40"
|
|
||||||
@click="isOpen = false"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
button {
|
/* Override Quasar generic styles if necessary */
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,50 @@
|
||||||
/**
|
/**
|
||||||
* @file default.vue
|
* @file default.vue
|
||||||
* @description Layout หลักสำหรับหน้าเว็บของผู้ใช้ (Authenticated Users)
|
* @description Layout หลักสำหรับหน้าเว็บของผู้ใช้ (Authenticated Users)
|
||||||
* ประกอบด้วย Header (Navbar) และ Mobile Navigation
|
* Uses Quasar QLayout for responsive structure.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const leftDrawerOpen = ref(false)
|
||||||
|
|
||||||
|
const toggleLeftDrawer = () => {
|
||||||
|
leftDrawerOpen.value = !leftDrawerOpen.value
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- App Shell: คอนเทนเนอร์หลักของแอปพลิเคชัน -->
|
<q-layout view="hHh lpr lFf" class="bg-slate-50 font-inter">
|
||||||
<div class="app-shell min-h-screen transition-colors duration-200">
|
<!-- Header -->
|
||||||
<!-- Header: แถบเมนูด้านบน -->
|
<q-header bordered class="bg-white text-slate-800">
|
||||||
<AppHeader />
|
<AppHeader @toggle-sidebar="toggleLeftDrawer" />
|
||||||
|
</q-header>
|
||||||
|
|
||||||
<!-- Main Content Area: ส่วนแสดงเนื้อหาหลัก -->
|
<!-- Sidebar (Drawer) -->
|
||||||
<main class="app-main">
|
<q-drawer
|
||||||
<slot />
|
v-model="leftDrawerOpen"
|
||||||
</main>
|
bordered
|
||||||
|
class="bg-white"
|
||||||
|
:width="260"
|
||||||
|
>
|
||||||
|
<AppSidebar />
|
||||||
|
</q-drawer>
|
||||||
|
|
||||||
<!-- Mobile Bottom Navigation: แถบเมนูด้านล่าง (แสดงเฉพาะมือถือ) -->
|
<!-- Main Content -->
|
||||||
<MobileNav />
|
<q-page-container>
|
||||||
</div>
|
<q-page class="relative">
|
||||||
|
<slot />
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
|
||||||
|
<!-- Mobile Bottom Nav -->
|
||||||
|
<q-footer v-if="$q.screen.lt.md" bordered class="bg-white text-primary">
|
||||||
|
<MobileNav />
|
||||||
|
</q-footer>
|
||||||
|
</q-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Ensure fonts are applied */
|
||||||
|
.font-inter {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,39 +2,32 @@
|
||||||
/**
|
/**
|
||||||
* @file landing.vue
|
* @file landing.vue
|
||||||
* @description Layout for the landing page (public facing).
|
* @description Layout for the landing page (public facing).
|
||||||
* Applies a dark theme by default and includes the public header and footer.
|
* Uses Quasar QLayout with overlay header.
|
||||||
*/
|
*/
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Landing Layout Container: Forces Dark Theme -->
|
<q-layout view="lHh LpR lFf" class="bg-slate-900 text-slate-100 font-inter">
|
||||||
<div class="landing-layout dark">
|
|
||||||
<LandingHeader />
|
|
||||||
|
|
||||||
<!-- Main Content Area: Grows to fill available space -->
|
<!-- Header (Transparent & Overlay) -->
|
||||||
<main class="flex-grow">
|
<q-header class="bg-transparent" style="height: auto;">
|
||||||
<slot />
|
<LandingHeader />
|
||||||
</main>
|
</q-header>
|
||||||
|
|
||||||
<LandingFooter />
|
<!-- Main Content -->
|
||||||
</div>
|
<!-- padding-top: 0 forces content to go under the header (Hero effect) -->
|
||||||
|
<q-page-container style="padding-top: 0 !important;">
|
||||||
|
<q-page>
|
||||||
|
<slot />
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
|
||||||
|
|
||||||
|
</q-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/*
|
.font-inter {
|
||||||
Layout Styling:
|
font-family: 'Inter', sans-serif;
|
||||||
- Flexbox column layout to stick footer to bottom.
|
|
||||||
- Dark background base.
|
|
||||||
*/
|
|
||||||
.landing-layout {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: #0f172a; /* Slate 900 */
|
|
||||||
color: #f1f5f9; /* Slate 100 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-grow {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
10
Frontend-Learner/package-lock.json
generated
10
Frontend-Learner/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "learner-ui",
|
"name": "learner-ui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||||
"nuxt": "^3.11.2",
|
"nuxt": "^3.11.2",
|
||||||
"nuxt-quasar-ui": "^3.0.0",
|
"nuxt-quasar-ui": "^3.0.0",
|
||||||
|
|
@ -1378,6 +1379,15 @@
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@heroicons/vue": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": ">= 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||||
"nuxt": "^3.11.2",
|
"nuxt": "^3.11.2",
|
||||||
"nuxt-quasar-ui": "^3.0.0",
|
"nuxt-quasar-ui": "^3.0.0",
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,10 @@ const forgotRules = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInput = (val: string) => {
|
const handleInput = (val: string | number | null) => {
|
||||||
forgotForm.email = val
|
const value = String(val || '')
|
||||||
if (/[\u0E00-\u0E7F]/.test(val)) {
|
forgotForm.email = value
|
||||||
|
if (/[\u0E00-\u0E7F]/.test(value)) {
|
||||||
errors.value.email = 'ห้ามใส่ภาษาไทย'
|
errors.value.email = 'ห้ามใส่ภาษาไทย'
|
||||||
} else {
|
} else {
|
||||||
if (errors.value.email === 'ห้ามใส่ภาษาไทย') {
|
if (errors.value.email === 'ห้ามใส่ภาษาไทย') {
|
||||||
|
|
@ -59,58 +60,115 @@ const sendResetLink = async () => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
forgotStep.value = 'success'
|
forgotStep.value = 'success'
|
||||||
} else {
|
} else {
|
||||||
|
// Basic alert for now, could be improved with q-notify later
|
||||||
alert(result.error || 'ไม่สามารถส่งลิงก์รีเซ็ตได้ กรุณาลองใหม่')
|
alert(result.error || 'ไม่สามารถส่งลิงก์รีเซ็ตได้ กรุณาลองใหม่')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card" style="width: 100%; max-width: 440px;">
|
<div class="flex items-center justify-center min-h-screen bg-slate-900 font-inter p-4 relative overflow-hidden">
|
||||||
<!-- Loading Overlay -->
|
|
||||||
<LoadingSpinner v-if="isLoading" full-page text="กำลังดำเนินการ..." />
|
|
||||||
|
|
||||||
<!-- Logo area -->
|
<!-- Background Decoration -->
|
||||||
<div class="flex flex-col items-center mb-6">
|
<div class="absolute top-[-20%] left-[-10%] w-[600px] h-[600px] bg-blue-600/5 rounded-full blur-[120px] pointer-events-none"></div>
|
||||||
<div
|
<div class="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-indigo-600/5 rounded-full blur-[120px] pointer-events-none"></div>
|
||||||
style="width: 48px; height: 48px; background: #eff6ff; color: #3b82f6; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 24px; margin-bottom: 16px;"
|
|
||||||
>
|
|
||||||
E
|
|
||||||
</div>
|
|
||||||
<h1 style="font-size: 24px; margin-bottom: 8px;">e-Learning Platform</h1>
|
|
||||||
<p class="text-slate-700 dark:text-slate-400 text-sm">รีเซ็ตรหัณผ่านของคุณ</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- MAIN CONTENT -->
|
<q-card class="w-full max-w-[480px] shadow-2xl rounded-2xl bg-slate-800 text-white border border-slate-700 relative z-10 q-pa-xl">
|
||||||
<div>
|
|
||||||
<!-- Step 1: Request Email Form -->
|
<!-- Logo area -->
|
||||||
<form v-if="forgotStep === 'initial'" @submit.prevent="sendResetLink">
|
<div class="flex flex-col items-center mb-10">
|
||||||
<p class="text-slate-700 dark:text-slate-400 text-sm mb-6">กรุณากรอกอีเมลเพื่อรับลิงก์รีเซ็ตรหัณผ่าน</p>
|
<div class="w-14 h-14 bg-white text-blue-600 rounded-2xl flex items-center justify-center font-extrabold text-3xl mb-6 shadow-lg shadow-white/10">
|
||||||
<FormInput
|
E
|
||||||
:model-value="forgotForm.email"
|
</div>
|
||||||
label="อีเมล"
|
<h1 class="text-3xl font-bold text-white mb-3">e-Learning Platform</h1>
|
||||||
type="email"
|
<p class="text-slate-400 text-base">รีเซ็ตรหัสผ่านของคุณ</p>
|
||||||
placeholder="student@example.com"
|
|
||||||
:error="errors.email"
|
|
||||||
required
|
|
||||||
@update:model-value="handleInput"
|
|
||||||
/>
|
|
||||||
<button type="submit" class="btn btn-primary w-full mb-4" :disabled="isLoading">
|
|
||||||
<LoadingSpinner v-if="isLoading" size="sm" />
|
|
||||||
<span v-else>ส่งลิงก์รีเซ็ต</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<!-- Navigation Links -->
|
|
||||||
<div class="text-center mt-6">
|
|
||||||
<NuxtLink to="/auth/login" class="text-sm font-bold text-slate-500 hover:text-slate-800 transition-colors">
|
|
||||||
← กลับไปหน้าเข้าสู่ระบบ
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- MAIN CONTENT -->
|
||||||
|
<div>
|
||||||
|
<!-- Step 1: Request Email Form -->
|
||||||
|
<form v-if="forgotStep === 'initial'" @submit.prevent="sendResetLink" class="flex flex-col gap-5">
|
||||||
|
|
||||||
|
<p class="text-slate-400 text-base mb-2 text-center">กรุณากรอกอีเมลเพื่อรับลิงก์รีเซ็ตรหัสผ่าน</p>
|
||||||
|
|
||||||
|
<!-- Email Input -->
|
||||||
|
<div>
|
||||||
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">อีเมล <span class="text-red-500">*</span></div>
|
||||||
|
<q-input
|
||||||
|
:model-value="forgotForm.email"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
dark
|
||||||
|
placeholder="student@example.com"
|
||||||
|
class="rounded-lg custom-dark-input"
|
||||||
|
color="primary"
|
||||||
|
:error="!!errors.email"
|
||||||
|
:error-message="errors.email"
|
||||||
|
@update:model-value="handleInput"
|
||||||
|
hide-bottom-space
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
unelevated
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
|
class="w-full h-12 text-lg font-bold shadow-lg shadow-blue-500/20 bg-blue-600 hover:bg-blue-500 mt-4"
|
||||||
|
:loading="isLoading"
|
||||||
|
>
|
||||||
|
ส่งลิงก์รีเซ็ต
|
||||||
|
<template v-slot:loading>
|
||||||
|
<q-spinner-dots color="white" />
|
||||||
|
</template>
|
||||||
|
</q-btn>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Success Message -->
|
||||||
|
<div v-else class="text-center animate-fade-in">
|
||||||
|
<div class="mb-6 flex justify-center">
|
||||||
|
<div class="w-16 h-16 bg-green-500/10 rounded-full flex items-center justify-center text-green-500 mb-4">
|
||||||
|
<q-icon name="check_circle" size="32px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-white mb-2">ส่งลิงก์เรียบร้อยแล้ว!</h3>
|
||||||
|
<p class="text-slate-400 mb-6">กรุณาตรวจสอบอีเมลของคุณเพื่อดำเนินการต่อ</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Links -->
|
||||||
|
<div class="text-center pt-6 border-t border-slate-700 mt-6">
|
||||||
|
<NuxtLink to="/auth/login" class="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl text-slate-400 text-base font-bold hover:text-white hover:bg-slate-700 transition-all duration-300 group">
|
||||||
|
<q-icon name="arrow_back" class="text-lg transform group-hover:-translate-x-1 transition-transform" />
|
||||||
|
กลับไปหน้าเข้าสู่ระบบ
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.card {
|
.font-inter {
|
||||||
box-shadow: var(--shadow-md);
|
font-family: 'Inter', 'Prompt', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom dark input styling */
|
||||||
|
.custom-dark-input :deep(.q-field__control) {
|
||||||
|
background: #334155 !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.custom-dark-input :deep(.q-field__control:before) {
|
||||||
|
border-color: #475569;
|
||||||
|
}
|
||||||
|
.custom-dark-input :deep(.q-field--focused .q-field__control:after) {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.5s ease-out;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
* @file login.vue
|
* @file login.vue
|
||||||
* @description หน้าเข้าสู่ระบบ (Login Page)
|
* @description หน้าเข้าสู่ระบบ (Login Page)
|
||||||
* รองรับการเข้าสู่ระบบด้วย Email/Password
|
* รองรับการเข้าสู่ระบบด้วย Email/Password
|
||||||
|
* NOTE: ไม่เปลี่ยนสี และไม่เปลี่ยนการเรียก API login
|
||||||
*/
|
*/
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -19,6 +20,8 @@ const { login, user } = useAuth()
|
||||||
const { errors, validate, clearFieldError } = useFormValidation()
|
const { errors, validate, clearFieldError } = useFormValidation()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const rememberMe = ref(false)
|
||||||
|
const showPassword = ref(false)
|
||||||
|
|
||||||
// Form data model
|
// Form data model
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
|
|
@ -26,162 +29,218 @@ const loginForm = reactive({
|
||||||
password: ''
|
password: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type LoginField = keyof typeof loginForm
|
||||||
|
|
||||||
// Validation rules definition
|
// Validation rules definition
|
||||||
// กำหนดกฎการตรวจสอบข้อมูล (Validation Rules)
|
// กำหนดกฎการตรวจสอบข้อมูล (Validation Rules)
|
||||||
const loginRules = {
|
const loginRules = {
|
||||||
email: {
|
email: {
|
||||||
rules: {
|
rules: {
|
||||||
required: true,
|
required: true,
|
||||||
email: true,
|
email: true,
|
||||||
custom: (val: string) => /[\u0E00-\u0E7F]/.test(val) ? 'ห้ามใส่ภาษาไทย' : null
|
custom: (val: string) => (/[\u0E00-\u0E7F]/.test(val) ? 'ห้ามใส่ภาษาไทย' : null)
|
||||||
},
|
},
|
||||||
label: 'อีเมล',
|
label: 'อีเมล',
|
||||||
messages: {
|
messages: {
|
||||||
required: 'กรุณากรอกอีเมลของคุณ',
|
required: 'กรุณากรอกอีเมลของคุณ',
|
||||||
email: 'กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)'
|
email: 'กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
rules: {
|
rules: {
|
||||||
required: true,
|
required: true,
|
||||||
minLength: 8,
|
minLength: 8,
|
||||||
custom: (val: string) => /[\u0E00-\u0E7F]/.test(val) ? 'ห้ามใส่ภาษาไทย' : null
|
custom: (val: string) => (/[\u0E00-\u0E7F]/.test(val) ? 'ห้ามใส่ภาษาไทย' : null)
|
||||||
},
|
},
|
||||||
label: 'รหัสผ่าน',
|
label: 'รหัสผ่าน',
|
||||||
messages: {
|
messages: {
|
||||||
required: 'กรุณากรอกรหัสผ่าน',
|
required: 'กรุณากรอกรหัสผ่าน',
|
||||||
minLength: 'กรุณากรอกรหัสผ่าน (อย่างน้อย 8 ตัวอักษร)'
|
minLength: 'กรุณากรอกรหัสผ่าน (อย่างน้อย 8 ตัวอักษร)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} as const
|
||||||
|
|
||||||
/**
|
|
||||||
* ฟังก์ชันตรวจสอบความถูกต้องของฟอร์มและเรียก API login
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* จัดการเมื่อมีการพิมพ์ข้อมูล (Input Handler)
|
* จัดการเมื่อมีการพิมพ์ข้อมูล (Input Handler)
|
||||||
* - ลบ error เมื่อเริ่มพิมพ์ใหม่
|
* - ลบ error เมื่อเริ่มพิมพ์ใหม่
|
||||||
* - ตรวจสอบภาษาไทยแบบ real-time
|
* - ตรวจสอบภาษาไทยแบบ real-time
|
||||||
*/
|
*/
|
||||||
const handleInput = (field: keyof typeof loginForm, value: string) => {
|
const handleInput = (field: LoginField, value: string) => {
|
||||||
loginForm[field] = value
|
loginForm[field] = value
|
||||||
|
|
||||||
if (/[\u0E00-\u0E7F]/.test(value)) {
|
if (/[\u0E00-\u0E7F]/.test(value)) {
|
||||||
errors.value[field] = 'ห้ามใส่ภาษาไทย'
|
errors.value[field] = 'ห้ามใส่ภาษาไทย'
|
||||||
} else {
|
return
|
||||||
if (errors.value[field] === 'ห้ามใส่ภาษาไทย') {
|
}
|
||||||
clearFieldError(field)
|
|
||||||
}
|
if (errors.value[field] === 'ห้ามใส่ภาษาไทย') {
|
||||||
|
clearFieldError(field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ฟังก์ชันตรวจสอบความถูกต้องของฟอร์มและเรียก API login
|
||||||
|
*/
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!validate(loginForm, loginRules)) return
|
if (!validate(loginForm, loginRules)) return
|
||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
|
// ✅ ไม่เปลี่ยน API: ยังคงเรียก login({ email, password }) ตามเดิม
|
||||||
const result = await login({
|
const result = await login({
|
||||||
email: loginForm.email,
|
email: loginForm.email,
|
||||||
password: loginForm.password
|
password: loginForm.password
|
||||||
})
|
})
|
||||||
|
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
} else {
|
return
|
||||||
// Show error on specific fields
|
|
||||||
if (result.error === 'ไม่พบอีเมลในระบบ') {
|
|
||||||
errors.value.email = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'
|
|
||||||
errors.value.password = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'
|
|
||||||
} else if (result.error === 'Email ไม่ถูกต้อง') {
|
|
||||||
errors.value.email = result.error
|
|
||||||
} else {
|
|
||||||
errors.value.password = 'กรอกรหัสผ่านไม่ถูกต้อง'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show error on specific fields
|
||||||
|
if (result.error === 'ไม่พบอีเมลในระบบ') {
|
||||||
|
errors.value.email = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'
|
||||||
|
errors.value.password = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'
|
||||||
|
} else if (result.error === 'Email ไม่ถูกต้อง') {
|
||||||
|
errors.value.email = result.error
|
||||||
|
} else {
|
||||||
|
errors.value.password = 'กรอกรหัสผ่านไม่ถูกต้อง'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional: social login placeholder (ไม่แตะ API เดิม)
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
// TODO: implement when backend ready
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card" style="width: 100%; max-width: 440px;">
|
|
||||||
<!-- Loading Screen Overlay -->
|
|
||||||
<LoadingSpinner v-if="isLoading" full-page text="กำลังดำเนินการ..." />
|
|
||||||
|
|
||||||
<!-- Header / Logo Section -->
|
<!-- Background Decoration (Optional Subtle Glows to match cool dark feel) -->
|
||||||
<div class="flex flex-col items-center mb-6">
|
<div class="absolute top-[-20%] left-[-10%] w-[600px] h-[600px] bg-blue-600/5 rounded-full blur-[120px] pointer-events-none"></div>
|
||||||
<div
|
<div class="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-indigo-600/5 rounded-full blur-[120px] pointer-events-none"></div>
|
||||||
style="width: 48px; height: 48px; background: #eff6ff; color: #3b82f6; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 24px; margin-bottom: 16px;"
|
|
||||||
>
|
<q-card
|
||||||
E
|
class="w-full max-w-[480px] shadow-2xl rounded-2xl bg-slate-800 text-white border border-slate-700 relative z-10 q-pa-xl"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Header / Logo Section -->
|
||||||
|
<div class="flex flex-col items-center mb-10">
|
||||||
|
<div class="w-14 h-14 bg-white text-blue-600 rounded-2xl flex items-center justify-center font-extrabold text-3xl mb-6 shadow-lg shadow-white/10">
|
||||||
|
E
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-3">e-Learning Platform</h1>
|
||||||
|
<p class="text-slate-400 text-base">ยินดีต้อนรับกลับ! กรุณากรอกข้อมูลของคุณ</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 style="font-size: 24px; margin-bottom: 8px;">e-Learning Platform</h1>
|
|
||||||
<p class="text-muted text-sm">ยินดีต้อนรับกลับ! กรุณากรอกข้อมูลของคุณ</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form @submit.prevent="handleLogin" novalidate>
|
<form @submit.prevent="handleLogin" class="flex flex-col gap-5">
|
||||||
<!-- Email Input -->
|
|
||||||
<FormInput
|
<!-- Email Input -->
|
||||||
:model-value="loginForm.email"
|
<div>
|
||||||
label="อีเมล"
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">อีเมล <span class="text-red-500">*</span></div>
|
||||||
type="email"
|
<q-input
|
||||||
placeholder="student@example.com"
|
:model-value="loginForm.email"
|
||||||
:error="errors.email"
|
outlined
|
||||||
required
|
dense
|
||||||
@update:model-value="val => handleInput('email', val)"
|
dark
|
||||||
/>
|
placeholder="student@example.com"
|
||||||
<!-- Password Input -->
|
class="rounded-lg custom-dark-input"
|
||||||
<FormInput
|
color="primary"
|
||||||
:model-value="loginForm.password"
|
:error="!!errors.email"
|
||||||
label="รหัสผ่าน"
|
:error-message="errors.email"
|
||||||
type="password"
|
@update:model-value="(val: string | number | null) => handleInput('email', val as string)"
|
||||||
placeholder="••••••••"
|
hide-bottom-space
|
||||||
:error="errors.password"
|
>
|
||||||
required
|
</q-input>
|
||||||
@update:model-value="val => handleInput('password', val)"
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Helpers: Remember Me & Forgot Password -->
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<label class="flex items-center gap-2 text-sm text-muted">
|
|
||||||
<input type="checkbox"> จดจำฉัน
|
|
||||||
</label>
|
|
||||||
<NuxtLink to="/auth/forgot-password" class="text-sm" style="color: var(--primary); font-weight: 500;">ลืมรหัสผ่าน?</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<button type="submit" class="btn btn-primary w-full mb-4" :disabled="isLoading">
|
|
||||||
<LoadingSpinner v-if="isLoading" size="sm" />
|
|
||||||
<span v-else>เข้าสู่ระบบ</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
<!-- Password Input -->
|
||||||
|
<div>
|
||||||
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">รหัสผ่าน <span class="text-red-500">*</span></div>
|
||||||
|
<q-input
|
||||||
|
:model-value="loginForm.password"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
dark
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
placeholder="••••••••"
|
||||||
|
class="rounded-lg custom-dark-input"
|
||||||
|
color="primary"
|
||||||
|
:error="!!errors.password"
|
||||||
|
:error-message="errors.password"
|
||||||
|
@update:model-value="(val: string | number | null) => handleInput('password', val as string)"
|
||||||
|
hide-bottom-space
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer text-slate-400 hover:text-white transition-colors"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Helpers: Remember Me & Forgot Password -->
|
||||||
|
<div class="flex justify-between items-center text-sm">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="rememberMe"
|
||||||
|
label="จดจำฉัน"
|
||||||
|
size="sm"
|
||||||
|
dense
|
||||||
|
dark
|
||||||
|
color="white"
|
||||||
|
class="text-slate-300"
|
||||||
|
/>
|
||||||
|
<NuxtLink to="/auth/forgot-password" class="font-medium text-blue-400 hover:text-blue-300 transition-colors">
|
||||||
|
ลืมรหัสผ่าน?
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
unelevated
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
|
class="w-full h-12 text-lg font-bold shadow-lg shadow-blue-500/20 bg-blue-600 hover:bg-blue-500"
|
||||||
|
:loading="isLoading"
|
||||||
|
>
|
||||||
|
เข้าสู่ระบบ
|
||||||
|
<template v-slot:loading>
|
||||||
|
<q-spinner-dots color="white" />
|
||||||
|
</template>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
<!-- Social Login (Google) -->
|
<!-- Back to Landing Page -->
|
||||||
<button type="button" class="btn-google w-full mb-6 flex items-center justify-center gap-3">
|
<div class="text-center pt-6 border-t border-slate-700 mt-2">
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18">
|
<NuxtLink to="/" class="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl text-slate-400 text-base font-bold hover:text-white hover:bg-slate-700 transition-all duration-300 group">
|
||||||
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z" fill="#4285F4"/>
|
<q-icon name="arrow_back" class="text-lg transform group-hover:-translate-x-1 transition-transform" />
|
||||||
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.834.859-3.048.859-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
|
กลับไปหน้าแรก
|
||||||
<path d="M3.964 10.706c-.18-.54-.282-1.117-.282-1.706 0-.589.102-1.166.282-1.706V4.962H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z" fill="#FBBC05"/>
|
</NuxtLink>
|
||||||
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962l3.007 2.332c.708-2.127 2.692-3.711 5.036-3.711z" fill="#EA4335"/>
|
</div>
|
||||||
</svg>
|
</form>
|
||||||
<span>เข้าสู่ระบบด้วย Google</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Back to Landing Page -->
|
</q-card>
|
||||||
<div class="text-center pt-6 border-t border-gray-100">
|
|
||||||
<NuxtLink to="/" class="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl border border-gray-200 text-sm font-bold text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-blue-600 transition-all duration-300 group">
|
|
||||||
<span class="transform group-hover:-translate-x-1 transition-transform">←</span>
|
|
||||||
กลับไปหน้าแรก
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.card {
|
.font-inter {
|
||||||
box-shadow: var(--shadow-md);
|
font-family: 'Inter', 'Prompt', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom dark input styling to match screenshot: Dark background, light border */
|
||||||
|
.custom-dark-input :deep(.q-field__control) {
|
||||||
|
background: #334155 !important; /* Slate 700ish */
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.custom-dark-input :deep(.q-field__control:before) {
|
||||||
|
border-color: #475569; /* Slate 600 */
|
||||||
|
}
|
||||||
|
.custom-dark-input :deep(.q-field--focused .q-field__control:after) {
|
||||||
|
border-color: #3b82f6; /* Blue 500 */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
* @file register.vue
|
* @file register.vue
|
||||||
* @description User Registration Page.
|
* @description User Registration Page.
|
||||||
* Features a multi-step style form (though currently single view) for creating a new account.
|
* Features a multi-step style form (though currently single view) for creating a new account.
|
||||||
|
* Updated to match the Dark Theme design.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -15,11 +16,12 @@ useHead({
|
||||||
});
|
});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { register } = useAuth(); // Import register from useAuth
|
const { register } = useAuth();
|
||||||
const { errors, validate, clearFieldError } = useFormValidation();
|
const { errors, validate, clearFieldError } = useFormValidation();
|
||||||
|
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
const showPassword = ref(false);
|
||||||
|
const showConfirmPassword = ref(false);
|
||||||
|
|
||||||
const registerForm = reactive({
|
const registerForm = reactive({
|
||||||
prefix: "นาย",
|
prefix: "นาย",
|
||||||
|
|
@ -32,7 +34,6 @@ const registerForm = reactive({
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const registerRules = {
|
const registerRules = {
|
||||||
username: {
|
username: {
|
||||||
rules: {
|
rules: {
|
||||||
|
|
@ -88,58 +89,63 @@ const registerRules = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUsernameInput = (val: string) => {
|
const prefixOptions = ['นาย', 'นาง', 'นางสาว']
|
||||||
registerForm.username = val;
|
|
||||||
if (/[\u0E00-\u0E7F]/.test(val)) {
|
// handlers
|
||||||
|
const onUsernameInput = (val: string | number | null) => {
|
||||||
|
const value = String(val || '')
|
||||||
|
registerForm.username = value;
|
||||||
|
if (/[\u0E00-\u0E7F]/.test(value)) {
|
||||||
errors.value.username = 'ห้ามใส่ภาษาไทย';
|
errors.value.username = 'ห้ามใส่ภาษาไทย';
|
||||||
} else {
|
} else {
|
||||||
// Only clear if the error is specifically about Thai chars,
|
if (errors.value.username === 'ห้ามใส่ภาษาไทย') clearFieldError('username');
|
||||||
// otherwise we might clear minLength errors undesirably
|
|
||||||
if (errors.value.username === 'ห้ามใส่ภาษาไทย') {
|
|
||||||
clearFieldError('username');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPhoneInput = (val: string) => {
|
const onPhoneInput = (val: string | number | null) => {
|
||||||
registerForm.phone = val;
|
const value = String(val || '')
|
||||||
if (/\D/.test(val)) {
|
registerForm.phone = value;
|
||||||
|
if (/\D/.test(value)) {
|
||||||
errors.value.phone = 'กรุณากรอกเฉพาะตัวเลข';
|
errors.value.phone = 'กรุณากรอกเฉพาะตัวเลข';
|
||||||
} else {
|
} else {
|
||||||
if (errors.value.phone === 'กรุณากรอกเฉพาะตัวเลข') clearFieldError('phone');
|
if (errors.value.phone === 'กรุณากรอกเฉพาะตัวเลข') clearFieldError('phone');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEmailInput = (val: string) => {
|
const onEmailInput = (val: string | number | null) => {
|
||||||
registerForm.email = val;
|
const value = String(val || '')
|
||||||
if (/[\u0E00-\u0E7F]/.test(val)) {
|
registerForm.email = value;
|
||||||
|
if (/[\u0E00-\u0E7F]/.test(value)) {
|
||||||
errors.value.email = 'ห้ามใส่ภาษาไทย';
|
errors.value.email = 'ห้ามใส่ภาษาไทย';
|
||||||
} else {
|
} else {
|
||||||
if (errors.value.email === 'ห้ามใส่ภาษาไทย') clearFieldError('email');
|
if (errors.value.email === 'ห้ามใส่ภาษาไทย') clearFieldError('email');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPasswordInput = (val: string) => {
|
const onPasswordInput = (val: string | number | null) => {
|
||||||
registerForm.password = val;
|
const value = String(val || '')
|
||||||
if (/[\u0E00-\u0E7F]/.test(val)) {
|
registerForm.password = value;
|
||||||
|
if (/[\u0E00-\u0E7F]/.test(value)) {
|
||||||
errors.value.password = 'ห้ามใส่ภาษาไทย';
|
errors.value.password = 'ห้ามใส่ภาษาไทย';
|
||||||
} else {
|
} else {
|
||||||
if (errors.value.password === 'ห้ามใส่ภาษาไทย') clearFieldError('password');
|
if (errors.value.password === 'ห้ามใส่ภาษาไทย') clearFieldError('password');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFirstNameInput = (val: string) => {
|
const onFirstNameInput = (val: string | number | null) => {
|
||||||
registerForm.firstName = val;
|
const value = String(val || '')
|
||||||
if (/\d/.test(val)) {
|
registerForm.firstName = value;
|
||||||
|
if (/\d/.test(value)) {
|
||||||
errors.value.firstName = 'ห้ามใส่ตัวเลข';
|
errors.value.firstName = 'ห้ามใส่ตัวเลข';
|
||||||
} else {
|
} else {
|
||||||
if (errors.value.firstName === 'ห้ามใส่ตัวเลข') clearFieldError('firstName');
|
if (errors.value.firstName === 'ห้ามใส่ตัวเลข') clearFieldError('firstName');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLastNameInput = (val: string) => {
|
const onLastNameInput = (val: string | number | null) => {
|
||||||
registerForm.lastName = val;
|
const value = String(val || '')
|
||||||
if (/\d/.test(val)) {
|
registerForm.lastName = value;
|
||||||
|
if (/\d/.test(value)) {
|
||||||
errors.value.lastName = 'ห้ามใส่ตัวเลข';
|
errors.value.lastName = 'ห้ามใส่ตัวเลข';
|
||||||
} else {
|
} else {
|
||||||
if (errors.value.lastName === 'ห้ามใส่ตัวเลข') clearFieldError('lastName');
|
if (errors.value.lastName === 'ห้ามใส่ตัวเลข') clearFieldError('lastName');
|
||||||
|
|
@ -151,7 +157,6 @@ const handleRegister = async () => {
|
||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
// Map prefix to { th, en }
|
|
||||||
const prefixMap: Record<string, string> = {
|
const prefixMap: Record<string, string> = {
|
||||||
'นาย': 'Mr.',
|
'นาย': 'Mr.',
|
||||||
'นาง': 'Mrs.',
|
'นาง': 'Mrs.',
|
||||||
|
|
@ -185,183 +190,235 @@ const handleRegister = async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card auth-card" style="width: 100%; max-width: 480px">
|
<div class="flex items-center justify-center min-h-screen bg-slate-900 font-inter p-4 relative overflow-hidden">
|
||||||
<!-- Loading Overlay -->
|
|
||||||
<LoadingSpinner v-if="isLoading" full-page text="กำลังดำเนินการ..." />
|
<!-- Background Decoration -->
|
||||||
|
<div class="absolute top-[-20%] left-[-10%] w-[600px] h-[600px] bg-blue-600/5 rounded-full blur-[120px] pointer-events-none"></div>
|
||||||
|
<div class="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-indigo-600/5 rounded-full blur-[120px] pointer-events-none"></div>
|
||||||
|
|
||||||
<!-- Title Area -->
|
<q-card class="w-full max-w-[740px] shadow-2xl rounded-2xl bg-slate-800 text-white border border-slate-700 relative z-10 q-pa-xl">
|
||||||
<div class="text-center mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">e-Learning Platform</h1>
|
|
||||||
<p class="text-gray-400 text-sm">
|
|
||||||
ยินดีต้อนรับกลับ! กรุณากรอกข้อมูลของคุณ
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleRegister">
|
<!-- Header -->
|
||||||
<FormInput
|
<div class="flex flex-col items-center mb-10">
|
||||||
:model-value="registerForm.username"
|
<div class="w-14 h-14 bg-white text-blue-600 rounded-2xl flex items-center justify-center font-extrabold text-3xl mb-6 shadow-lg shadow-white/10">
|
||||||
label="ชื่อผู้ใช้"
|
E
|
||||||
placeholder="username"
|
|
||||||
:error="errors.username"
|
|
||||||
required
|
|
||||||
class="dark-form-input"
|
|
||||||
@update:model-value="onUsernameInput"
|
|
||||||
/>
|
|
||||||
<FormInput
|
|
||||||
:model-value="registerForm.email"
|
|
||||||
label="อีเมล"
|
|
||||||
type="email"
|
|
||||||
placeholder="student@example.com"
|
|
||||||
:error="errors.email"
|
|
||||||
required
|
|
||||||
class="dark-form-input"
|
|
||||||
@update:model-value="onEmailInput"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormInput
|
|
||||||
:model-value="registerForm.password"
|
|
||||||
label="รหัสผ่าน"
|
|
||||||
type="password"
|
|
||||||
placeholder="สร้างรหัสผ่าน (อย่างน้อย 8 ตัวอักษร)"
|
|
||||||
:error="errors.password"
|
|
||||||
required
|
|
||||||
class="dark-form-input"
|
|
||||||
@update:model-value="onPasswordInput"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormInput
|
|
||||||
v-model="registerForm.confirmPassword"
|
|
||||||
label="ยืนยันรหัสผ่าน"
|
|
||||||
type="password"
|
|
||||||
placeholder="ยืนยันรหัสผ่านอีกครั้ง"
|
|
||||||
:error="errors.confirmPassword"
|
|
||||||
required
|
|
||||||
class="dark-form-input"
|
|
||||||
@update:model-value="clearFieldError('confirmPassword')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid-12" style="gap: 16px; margin-bottom: 0">
|
|
||||||
<div class="col-span-4">
|
|
||||||
<label class="input-label text-gray-300">คำนำหน้า</label>
|
|
||||||
<select
|
|
||||||
v-model="registerForm.prefix"
|
|
||||||
class="input-field dark-input"
|
|
||||||
style="height: 42px"
|
|
||||||
>
|
|
||||||
<option value="นาย">นาย</option>
|
|
||||||
<option value="นาง">นาง</option>
|
|
||||||
<option value="นางสาว">นางสาว</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-8">
|
|
||||||
<FormInput
|
|
||||||
:model-value="registerForm.firstName"
|
|
||||||
label="ชื่อ"
|
|
||||||
placeholder=""
|
|
||||||
:error="errors.firstName"
|
|
||||||
required
|
|
||||||
class="dark-form-input"
|
|
||||||
@update:model-value="onFirstNameInput"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-3">e-Learning Platform</h1>
|
||||||
|
<p class="text-slate-400 text-base">สร้างบัญชีผู้ใช้งานใหม่</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormInput
|
<form @submit.prevent="handleRegister" class="flex flex-col gap-5">
|
||||||
:model-value="registerForm.lastName"
|
|
||||||
label="นามสกุล"
|
<!-- Username -->
|
||||||
placeholder=""
|
<div>
|
||||||
:error="errors.lastName"
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">ชื่อผู้ใช้ <span class="text-red-500">*</span></div>
|
||||||
required
|
<q-input
|
||||||
class="dark-form-input"
|
:model-value="registerForm.username"
|
||||||
@update:model-value="onLastNameInput"
|
outlined
|
||||||
/>
|
dense
|
||||||
|
dark
|
||||||
|
placeholder="username"
|
||||||
|
class="rounded-lg custom-dark-input"
|
||||||
|
color="primary"
|
||||||
|
:error="!!errors.username"
|
||||||
|
:error-message="errors.username"
|
||||||
|
@update:model-value="onUsernameInput"
|
||||||
|
hide-bottom-space
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormInput
|
<!-- Email -->
|
||||||
:model-value="registerForm.phone"
|
<div>
|
||||||
label="เบอร์โทรศัพท์"
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">อีเมล <span class="text-red-500">*</span></div>
|
||||||
type="tel"
|
<q-input
|
||||||
placeholder=""
|
:model-value="registerForm.email"
|
||||||
:error="errors.phone"
|
outlined
|
||||||
required
|
dense
|
||||||
class="dark-form-input"
|
dark
|
||||||
@update:model-value="onPhoneInput"
|
type="email"
|
||||||
/>
|
placeholder="student@example.com"
|
||||||
|
class="rounded-lg custom-dark-input"
|
||||||
|
color="primary"
|
||||||
|
:error="!!errors.email"
|
||||||
|
:error-message="errors.email"
|
||||||
|
@update:model-value="onEmailInput"
|
||||||
|
hide-bottom-space
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<!-- Password -->
|
||||||
type="submit"
|
<div>
|
||||||
class="btn btn-primary w-full mb-4 mt-2"
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">รหัสผ่าน <span class="text-red-500">*</span></div>
|
||||||
:disabled="isLoading"
|
<q-input
|
||||||
style="height: 44px; font-size: 16px"
|
:model-value="registerForm.password"
|
||||||
>
|
outlined
|
||||||
<LoadingSpinner v-if="isLoading" size="sm" />
|
dense
|
||||||
<span v-else>สร้างบัญชี</span>
|
dark
|
||||||
</button>
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
placeholder="สร้างรหัสผ่าน (อย่างน้อย 8 ตัวอักษร)"
|
||||||
|
class="rounded-lg custom-dark-input"
|
||||||
|
color="primary"
|
||||||
|
:error="!!errors.password"
|
||||||
|
:error-message="errors.password"
|
||||||
|
@update:model-value="onPasswordInput"
|
||||||
|
hide-bottom-space
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer text-slate-400 hover:text-white transition-colors"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-center mt-6 text-sm">
|
<!-- Confirm Password -->
|
||||||
<span class="text-muted">มีบัญชีอยู่แล้ว? </span>
|
<div>
|
||||||
<NuxtLink to="/auth/login" class="font-bold text-primary hover:underline">เข้าสู่ระบบ</NuxtLink>
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">ยืนยันรหัสผ่าน <span class="text-red-500">*</span></div>
|
||||||
</div>
|
<q-input
|
||||||
</form>
|
v-model="registerForm.confirmPassword"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
dark
|
||||||
|
:type="showConfirmPassword ? 'text' : 'password'"
|
||||||
|
placeholder="ยืนยันรหัสผ่านอีกครั้ง"
|
||||||
|
class="rounded-lg custom-dark-input"
|
||||||
|
color="primary"
|
||||||
|
:error="!!errors.confirmPassword"
|
||||||
|
:error-message="errors.confirmPassword"
|
||||||
|
@update:model-value="clearFieldError('confirmPassword')"
|
||||||
|
hide-bottom-space
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="showConfirmPassword ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer text-slate-400 hover:text-white transition-colors"
|
||||||
|
@click="showConfirmPassword = !showConfirmPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name Section -->
|
||||||
|
<div class="grid grid-cols-12 gap-4">
|
||||||
|
<!-- Prefix -->
|
||||||
|
<div class="col-span-4">
|
||||||
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">คำนำหน้า</div>
|
||||||
|
<q-select
|
||||||
|
v-model="registerForm.prefix"
|
||||||
|
:options="prefixOptions"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
dark
|
||||||
|
class="rounded-lg custom-dark-input"
|
||||||
|
color="primary"
|
||||||
|
options-cover
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- First Name -->
|
||||||
|
<div class="col-span-8">
|
||||||
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">ชื่อ <span class="text-red-500">*</span></div>
|
||||||
|
<q-input
|
||||||
|
:model-value="registerForm.firstName"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
dark
|
||||||
|
placeholder=""
|
||||||
|
class="rounded-lg custom-dark-input"
|
||||||
|
color="primary"
|
||||||
|
:error="!!errors.firstName"
|
||||||
|
:error-message="errors.firstName"
|
||||||
|
@update:model-value="onFirstNameInput"
|
||||||
|
hide-bottom-space
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Name -->
|
||||||
|
<div>
|
||||||
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">นามสกุล <span class="text-red-500">*</span></div>
|
||||||
|
<q-input
|
||||||
|
:model-value="registerForm.lastName"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
dark
|
||||||
|
placeholder=""
|
||||||
|
class="rounded-lg custom-dark-input"
|
||||||
|
color="primary"
|
||||||
|
:error="!!errors.lastName"
|
||||||
|
:error-message="errors.lastName"
|
||||||
|
@update:model-value="onLastNameInput"
|
||||||
|
hide-bottom-space
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone -->
|
||||||
|
<div>
|
||||||
|
<div class="text-base font-semibold text-slate-300 mb-2 ml-1">เบอร์โทรศัพท์ <span class="text-red-500">*</span></div>
|
||||||
|
<q-input
|
||||||
|
:model-value="registerForm.phone"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
dark
|
||||||
|
type="tel"
|
||||||
|
placeholder=""
|
||||||
|
class="rounded-lg custom-dark-input"
|
||||||
|
color="primary"
|
||||||
|
:error="!!errors.phone"
|
||||||
|
:error-message="errors.phone"
|
||||||
|
@update:model-value="onPhoneInput"
|
||||||
|
hide-bottom-space
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
unelevated
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
|
class="w-full h-12 text-lg font-bold shadow-lg shadow-blue-500/20 bg-blue-600 hover:bg-blue-500 mt-4"
|
||||||
|
:loading="isLoading"
|
||||||
|
>
|
||||||
|
สร้างบัญชี
|
||||||
|
<template v-slot:loading>
|
||||||
|
<q-spinner-dots color="white" />
|
||||||
|
</template>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<!-- Login Link -->
|
||||||
|
<div class="text-center pt-6 border-t border-slate-700 mt-2 text-base text-slate-400">
|
||||||
|
มีบัญชีอยู่แล้ว?
|
||||||
|
<NuxtLink to="/auth/login" class="font-bold text-blue-400 hover:text-blue-300 transition-colors ml-1">
|
||||||
|
เข้าสู่ระบบ
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Force Dark Theme for Card */
|
.font-inter {
|
||||||
.auth-card {
|
font-family: 'Inter', 'Prompt', sans-serif;
|
||||||
background-color: #1e293b; /* Slate 800 */
|
|
||||||
border-color: transparent;
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5),
|
|
||||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
||||||
color: #f8fafc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom dark input styling */
|
||||||
|
.custom-dark-input :deep(.q-field__control) {
|
||||||
/* Input Overrides for Dark Mode integration within this page */
|
background: #334155 !important;
|
||||||
.dark-input {
|
border-radius: 8px;
|
||||||
background-color: #334155; /* Slate 700 */
|
|
||||||
border-color: #475569; /* Slate 600 */
|
|
||||||
color: #f1f5f9;
|
|
||||||
}
|
}
|
||||||
|
.custom-dark-input :deep(.q-field__control:before) {
|
||||||
.dark-input:focus {
|
border-color: #475569;
|
||||||
|
}
|
||||||
|
.custom-dark-input :deep(.q-field--focused .q-field__control:after) {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Deep selector for FormInput component internals */
|
/* Adjust QSelect to match */
|
||||||
:deep(.input-field) {
|
.custom-dark-input :deep(.q-field__native) {
|
||||||
background-color: #334155 !important;
|
|
||||||
border-color: #475569 !important;
|
|
||||||
color: #f1f5f9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.input-field::placeholder) {
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.input-label) {
|
|
||||||
color: #cbd5e1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.input-label span.required-mark) {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Select option styling */
|
|
||||||
option {
|
|
||||||
background-color: #334155;
|
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
|
||||||
/*
|
|
||||||
Global Override for Auth Layout Background on this page context.
|
|
||||||
Ensures the entire page background matches the design requirement.
|
|
||||||
*/
|
|
||||||
.auth-shell {
|
|
||||||
background-color: #0f172a !important; /* Slate 900 */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,24 @@ const visibleCategories = computed(() => {
|
||||||
return showAllCategories.value ? categories.value : categories.value.slice(0, 8);
|
return showAllCategories.value ? categories.value : categories.value.slice(0, 8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// เตรียม Options สำหรับ QSelect Dropdown
|
||||||
|
const categoryOptions = computed(() => {
|
||||||
|
return categories.value.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
label: getLocalizedText(c.name),
|
||||||
|
value: c.id // q-select uses value by default if not constrained
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
// ฟังก์ชัน Toggle Category สำหรับ Chip Remove
|
||||||
|
const toggleCategory = (id: number) => {
|
||||||
|
if (selectedCategoryIds.value.includes(id)) {
|
||||||
|
selectedCategoryIds.value = selectedCategoryIds.value.filter(cid => cid !== id);
|
||||||
|
} else {
|
||||||
|
selectedCategoryIds.value.push(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 4. จัดการข้อมูลคอร์ส (Courses)
|
// 4. จัดการข้อมูลคอร์ส (Courses)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
@ -151,311 +169,257 @@ const filteredCourses = computed(() => {
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 6. User & Helpers
|
||||||
|
// ==========================================
|
||||||
|
const { currentUser } = useAuth();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="p-6 max-w-[1400px] mx-auto">
|
||||||
|
|
||||||
|
|
||||||
<!-- CATALOG VIEW: Browse courses -->
|
<!-- CATALOG VIEW: Browse courses -->
|
||||||
<div v-if="!showDetail">
|
<div v-if="!showDetail">
|
||||||
<!-- Search & Filters Header -->
|
|
||||||
<div
|
<!-- Top Header Area -->
|
||||||
class="flex justify-between items-center mb-6"
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||||
style="flex-wrap: wrap; gap: 16px"
|
<!-- Title -->
|
||||||
>
|
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
<h1 class="text-[28px] font-bold text-slate-900 dark:text-white">
|
รายการคอร์สทั้งหมด
|
||||||
{{ $t('discovery.title') }}
|
</h2>
|
||||||
</h1>
|
|
||||||
<div class="flex gap-3" style="flex-wrap: wrap">
|
<!-- Right Side: Search & Sort -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<!-- Search Input -->
|
<!-- Search Input -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
class="input-field text-slate-900 dark:text-white bg-white dark:bg-slate-800 placeholder:text-slate-500"
|
class="pl-4 pr-10 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:outline-none focus:border-slate-400 w-full md:w-64 text-sm"
|
||||||
:placeholder="$t('discovery.searchPlaceholder')"
|
placeholder="ค้นหาคอร์ส..."
|
||||||
style="padding-left: 36px; width: 240px"
|
/>
|
||||||
/>
|
<svg xmlns="http://www.w3.org/2000/svg" class="absolute right-3 top-2.5 h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort Dropdown (Dummy) -->
|
||||||
|
<div class="relative">
|
||||||
|
<select class="appearance-none pl-4 pr-10 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 text-sm focus:outline-none focus:border-slate-400 cursor-pointer">
|
||||||
|
<option>เรียงตาม: ล่าสุด</option>
|
||||||
|
<option>ราคา: ต่ำไปสูง</option>
|
||||||
|
<option>ราคา: สูงไปต่ำ</option>
|
||||||
|
</select>
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Sorting Select -->
|
|
||||||
<select
|
|
||||||
class="input-field bg-white dark:bg-slate-800"
|
|
||||||
style="width: auto; color: #0f172a"
|
|
||||||
>
|
|
||||||
<option style="color: #0f172a">{{ $t('discovery.sortRecent') }}</option>
|
|
||||||
<option style="color: #0f172a">{{ $t('discovery.sortPopular') }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Grid Layout -->
|
<!-- Main Layout: Sidebar + Grid -->
|
||||||
<div class="grid-12">
|
<div class="flex flex-col lg:flex-row gap-8 items-start">
|
||||||
<!-- Sidebar Filters -->
|
|
||||||
<div class="col-span-3">
|
<!-- LEFT SIDEBAR: Category Filter -->
|
||||||
<div class="card">
|
<div class="w-full lg:w-64 flex-shrink-0 sticky top-24">
|
||||||
<div class="mb-6">
|
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||||
<div
|
<q-expansion-item
|
||||||
class="flex items-center justify-between mb-4 cursor-pointer"
|
expand-separator
|
||||||
@click="isCategoryOpen = !isCategoryOpen"
|
:label="`หมวดหมู่ (${selectedCategoryIds.length})`"
|
||||||
>
|
class="font-bold text-slate-800 dark:text-white"
|
||||||
<h4 class="text-lg font-bold text-slate-900 dark:text-white">
|
header-class="bg-white dark:bg-slate-800"
|
||||||
{{ $t('discovery.categoryTitle') }} ({{ categories.length }})
|
default-opened
|
||||||
</h4>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5 text-slate-400 transition-transform duration-200"
|
|
||||||
:class="{ 'rotate-180': !isCategoryOpen }"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
>
|
||||||
<path
|
<q-list class="bg-white dark:bg-slate-800 border-t border-slate-100 dark:border-slate-700">
|
||||||
stroke-linecap="round"
|
<q-item
|
||||||
stroke-linejoin="round"
|
v-for="cat in (showAllCategories ? categories : categories.slice(0, 4))"
|
||||||
stroke-width="2"
|
:key="cat.id"
|
||||||
d="M5 15l7-7 7 7"
|
tag="label"
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-checkbox v-model="selectedCategoryIds" :val="cat.id" color="primary" dense />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="text-sm font-medium text-slate-700 dark:text-slate-300">{{ getLocalizedText(cat.name) }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<!-- Show More/Less Button -->
|
||||||
|
<q-item
|
||||||
|
v-if="categories.length > 4"
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
@click="showAllCategories = !showAllCategories"
|
||||||
|
class="text-blue-600 dark:text-blue-400 font-bold text-sm"
|
||||||
|
>
|
||||||
|
<q-item-section>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{{ showAllCategories ? 'แสดงน้อยลง' : 'แสดงเพิ่มเติม' }}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" :class="showAllCategories ? 'rotate-180' : ''">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-expansion-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT CONTENT: Course Grid -->
|
||||||
|
<div class="flex-1 w-full">
|
||||||
|
<div v-if="filteredCourses.length > 0" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
|
<CourseCard
|
||||||
|
v-for="course in filteredCourses"
|
||||||
|
:key="course.id"
|
||||||
|
:title="course.title"
|
||||||
|
:price="course.price"
|
||||||
|
:description="course.description"
|
||||||
|
:rating="course.rating"
|
||||||
|
:lessons="course.lessons"
|
||||||
|
:image="course.thumbnail_url"
|
||||||
|
show-view-details
|
||||||
|
@view-details="selectCourse(course.id)"
|
||||||
/>
|
/>
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="isCategoryOpen" class="flex flex-col gap-1">
|
<!-- Empty State -->
|
||||||
<div
|
<div
|
||||||
v-for="cat in visibleCategories"
|
v-else
|
||||||
:key="cat.id"
|
class="flex flex-col items-center justify-center py-20 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700"
|
||||||
class="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700/50 group cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 -mx-4 px-4 transition-colors"
|
>
|
||||||
>
|
<div class="text-6xl mb-4">🔍</div>
|
||||||
<label
|
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3>
|
||||||
class="flex items-center gap-3 text-slate-700 dark:text-slate-300 cursor-pointer w-full"
|
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
|
||||||
>
|
{{ $t('discovery.emptyDesc') }}
|
||||||
<input
|
</p>
|
||||||
type="checkbox"
|
<button class="mt-6 font-bold text-blue-600 hover:underline" @click="searchQuery = ''; selectedCategoryIds = []">
|
||||||
:value="cat.id"
|
{{ $t('discovery.showAll') }}
|
||||||
v-model="selectedCategoryIds"
|
|
||||||
class="w-4 h-4 rounded border-slate-300 text-primary focus:ring-primary"
|
|
||||||
/>
|
|
||||||
{{ getLocalizedText(cat.name) }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="text-primary text-sm mt-4 font-medium hover:underline flex items-center gap-1"
|
|
||||||
@click="showAllCategories = !showAllCategories"
|
|
||||||
>
|
|
||||||
{{ showAllCategories ? $t('discovery.showLess') : $t('discovery.showMore') }}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4 transition-transform duration-200"
|
|
||||||
:class="{ 'rotate-180': showAllCategories }"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Course List -->
|
|
||||||
<div class="col-span-9" style="min-width: 0">
|
|
||||||
<div class="course-grid">
|
|
||||||
<CourseCard
|
|
||||||
v-for="course in filteredCourses"
|
|
||||||
:key="course.id"
|
|
||||||
:title="course.title"
|
|
||||||
:price="course.price"
|
|
||||||
:description="course.description"
|
|
||||||
:rating="course.rating"
|
|
||||||
:lessons="course.lessons"
|
|
||||||
:image="course.thumbnail_url"
|
|
||||||
show-view-details
|
|
||||||
@view-details="selectCourse(course.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div
|
|
||||||
v-if="filteredCourses.length === 0"
|
|
||||||
class="empty-state"
|
|
||||||
style="grid-column: 1 / -1"
|
|
||||||
>
|
|
||||||
<h3 class="empty-state-title">{{ $t('discovery.emptyTitle') }}</h3>
|
|
||||||
<p class="empty-state-description">
|
|
||||||
{{ $t('discovery.emptyDesc') }}
|
|
||||||
</p>
|
|
||||||
<button class="btn btn-secondary" @click="searchQuery = ''">
|
|
||||||
{{ $t('discovery.showAll') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination / Load More -->
|
|
||||||
<div class="load-more-wrap">
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary"
|
|
||||||
style="
|
|
||||||
border-color: #64748b;
|
|
||||||
color: white;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ $t('discovery.loadMore') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
|
<!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<button
|
<button
|
||||||
@click="showDetail = false"
|
@click="showDetail = false"
|
||||||
class="btn btn-secondary mb-6 inline-flex items-center gap-2"
|
class="mb-6 inline-flex items-center gap-2 text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors font-medium"
|
||||||
>
|
>
|
||||||
<span>←</span> {{ $t('discovery.backToCatalog') }}
|
<span>←</span> {{ $t('discovery.backToCatalog') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="isLoadingDetail" class="flex justify-center py-20">
|
<div v-if="isLoadingDetail" class="flex justify-center py-20">
|
||||||
<div class="spinner-border animate-spin inline-block w-8 h-8 border-4 rounded-full" role="status">
|
<div class="spinner-border animate-spin inline-block w-8 h-8 border-4 border-blue-500 rounded-full border-t-transparent" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="selectedCourse" class="grid-12">
|
<div v-else-if="selectedCourse" class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
<!-- Main Content (Left Column) -->
|
<!-- Main Content (Left Column) -->
|
||||||
<div class="col-span-8">
|
<div class="lg:col-span-8">
|
||||||
<!-- Hero Video Placeholder -->
|
<!-- Hero Video Placeholder -->
|
||||||
<div
|
<div
|
||||||
style="
|
class="w-full aspect-video bg-slate-900 rounded-3xl overflow-hidden relative shadow-lg mb-8 group"
|
||||||
width: 100%;
|
|
||||||
height: 400px;
|
|
||||||
background: var(--neutral-900);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
position: relative;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
overflow: hidden;
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="selectedCourse.thumbnail_url"
|
v-if="selectedCourse.thumbnail_url"
|
||||||
:src="selectedCourse.thumbnail_url"
|
:src="selectedCourse.thumbnail_url"
|
||||||
class="absolute inset-0 w-full h-full object-cover opacity-50"
|
class="absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-40 transition-opacity"
|
||||||
/>
|
/>
|
||||||
<!-- Play Button -->
|
<!-- Play Button -->
|
||||||
<div
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
class="relative z-10"
|
<div class="w-20 h-20 bg-white/20 backdrop-blur-md rounded-full flex items-center justify-center group-hover:scale-110 transition-transform cursor-pointer">
|
||||||
style="
|
<div class="w-16 h-16 bg-white rounded-full flex items-center justify-center shadow-xl">
|
||||||
width: 80px;
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-600 ml-1" viewBox="0 0 20 20" fill="currentColor">
|
||||||
height: 80px;
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
background: var(--primary);
|
</svg>
|
||||||
border-radius: 50%;
|
</div>
|
||||||
display: flex;
|
</div>
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-top: 15px solid transparent;
|
|
||||||
border-bottom: 15px solid transparent;
|
|
||||||
border-left: 25px solid white;
|
|
||||||
margin-left: 6px;
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-[32px] font-bold mb-4 text-slate-900 dark:text-white">
|
<h1 class="text-3xl md:text-4xl font-bold mb-4 text-slate-900 dark:text-white tracking-tight">
|
||||||
{{ getLocalizedText(selectedCourse.title) }}
|
{{ getLocalizedText(selectedCourse.title) }}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p class="text-slate-600 dark:text-slate-400 text-lg leading-relaxed mb-8">
|
||||||
class="text-slate-700 dark:text-slate-400 mb-6"
|
|
||||||
style="font-size: 1.1em; line-height: 1.7"
|
|
||||||
>
|
|
||||||
{{ getLocalizedText(selectedCourse.description) }}
|
{{ getLocalizedText(selectedCourse.description) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Course Syllabus / Outline (Dynamic) -->
|
<!-- Course Syllabus -->
|
||||||
<div class="card" v-if="selectedCourse.chapters && selectedCourse.chapters.length > 0">
|
<div class="bg-white dark:bg-slate-800 rounded-3xl p-8 shadow-sm border border-slate-100 dark:border-slate-700/50">
|
||||||
<h3 class="font-bold mb-4 text-slate-900 dark:text-white">
|
<h3 class="text-xl font-bold mb-6 text-slate-900 dark:text-white flex items-center gap-2">
|
||||||
{{ $t('course.courseContent') }}
|
<span class="w-1 h-6 bg-blue-500 rounded-full"></span>
|
||||||
|
{{ $t('course.courseContent') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div v-for="chapter in selectedCourse.chapters" :key="chapter.id" class="mb-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div v-for="(chapter, index) in selectedCourse.chapters" :key="chapter.id" class="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
|
||||||
class="flex justify-between p-4 rounded mb-2"
|
<div class="bg-slate-50 dark:bg-slate-800/80 p-4 flex justify-between items-center cursor-pointer">
|
||||||
style="background: #f3f4f6; border: 1px solid #e5e7eb"
|
<div class="font-bold text-slate-800 dark:text-slate-200">
|
||||||
>
|
<span class="opacity-50 mr-2">Chapter {{ index + 1 }}</span>
|
||||||
<span class="font-bold text-slate-900 dark:text-slate-900"
|
{{ getLocalizedText(chapter.title) }}
|
||||||
>{{ getLocalizedText(chapter.title) }}</span
|
</div>
|
||||||
>
|
<span class="text-xs font-bold px-3 py-1 bg-white dark:bg-slate-700 rounded-full border border-slate-200 dark:border-slate-600">
|
||||||
<span class="text-sm text-slate-600 dark:text-slate-400"
|
{{ chapter.lessons ? chapter.lessons.length : 0 }} Lessons
|
||||||
>{{ chapter.lessons ? chapter.lessons.length : 0 }} {{ $t('course.lessons') }}</span
|
</span>
|
||||||
>
|
</div>
|
||||||
</div>
|
<!-- Lessons -->
|
||||||
<div style="padding-left: 16px" v-if="chapter.lessons">
|
<div class="divide-y divide-slate-100 dark:divide-slate-700/50 bg-white dark:bg-slate-800">
|
||||||
<div
|
<div v-for="(lesson, lIndex) in chapter.lessons" :key="lesson.id" class="p-4 flex justify-between items-center hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||||
v-for="lesson in chapter.lessons"
|
<div class="flex items-center gap-3">
|
||||||
:key="lesson.id"
|
<div class="w-6 h-6 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-[10px] font-bold text-slate-500">
|
||||||
class="flex justify-between py-2 border-b"
|
{{ lIndex + 1 }}
|
||||||
style="border-color: var(--neutral-100)"
|
</div>
|
||||||
>
|
<span class="text-slate-700 dark:text-slate-300 font-medium text-sm">{{ getLocalizedText(lesson.title) }}</span>
|
||||||
<span class="text-sm text-slate-700 dark:text-slate-300">{{ getLocalizedText(lesson.title) }}</span>
|
</div>
|
||||||
<span class="text-sm text-slate-600 dark:text-slate-400"
|
<span class="text-xs text-slate-400">{{ lesson.duration_minutes || 0 }}:00</span>
|
||||||
>{{ lesson.duration_minutes || 0 }}:00</span
|
</div>
|
||||||
>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar (Right Column): Sticky CTA -->
|
<!-- Sidebar (Right Column) -->
|
||||||
<div class="col-span-4">
|
<div class="lg:col-span-4">
|
||||||
<div class="card" style="position: sticky; top: 88px">
|
<div class="bg-white dark:bg-slate-800 rounded-3xl p-6 shadow-lg border border-slate-100 dark:border-slate-700 sticky top-24">
|
||||||
<div class="mb-6">
|
<div class="mb-8 text-center bg-slate-50 dark:bg-slate-900/50 rounded-2xl p-6 border border-slate-100 dark:border-slate-700">
|
||||||
<span
|
<div class="text-sm text-slate-500 mb-1 font-medium">{{ selectedCourse.price ? 'ราคาคอร์ส' : 'ราคา' }}</div>
|
||||||
class="text-sm text-slate-600 dark:text-slate-400"
|
<h2 class="text-4xl font-black text-blue-600 dark:text-blue-400">
|
||||||
style="text-decoration: line-through"
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
class="text-primary font-bold"
|
|
||||||
style="font-size: 32px; margin: 0"
|
|
||||||
>
|
|
||||||
{{ selectedCourse.price || 'ฟรี' }}
|
{{ selectedCourse.price || 'ฟรี' }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="handleEnroll(selectedCourse.id)"
|
@click="handleEnroll(selectedCourse.id)"
|
||||||
class="btn btn-primary w-full mb-4 text-white"
|
class="w-full py-4 rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-bold text-lg shadow-lg hover:shadow-blue-500/30 hover:scale-[1.02] transform transition-all active:scale-95 disabled:opacity-70 disabled:cursor-not-allowed mb-4 flex items-center justify-center gap-2"
|
||||||
style="height: 48px; font-size: 16px"
|
|
||||||
:disabled="isEnrolling"
|
:disabled="isEnrolling"
|
||||||
>
|
>
|
||||||
<span v-if="isEnrolling" class="spinner-border animate-spin inline-block w-4 h-4 border-2 rounded-full mr-2"></span>
|
<span v-if="isEnrolling" class="block w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
|
||||||
{{ $t('course.enrollNow') }}
|
{{ $t('course.enrollNow') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="text-sm text-slate-600 dark:text-slate-400 mb-4">
|
<div class="text-sm space-y-3 pt-4 border-t border-slate-100 dark:border-slate-700">
|
||||||
<div
|
<div class="flex justify-between text-slate-600 dark:text-slate-400">
|
||||||
class="flex justify-between py-2 border-b"
|
<span>{{ $t('course.certificate') }}</span>
|
||||||
style="border-color: var(--neutral-100)"
|
<span class="font-bold text-slate-900 dark:text-white">{{ selectedCourse.have_certificate ? 'มีใบประกาศฯ' : 'ไม่มี' }}</span>
|
||||||
>
|
</div>
|
||||||
<span>{{ $t('course.certificate') }}</span>
|
<div class="flex justify-between text-slate-600 dark:text-slate-400">
|
||||||
<span class="font-bold">{{ selectedCourse.have_certificate ? $t('course.available') : '-' }}</span>
|
<span>ผู้สอน</span>
|
||||||
</div>
|
<span class="font-bold text-slate-900 dark:text-white">Professional Instructor</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-slate-600 dark:text-slate-400">
|
||||||
|
<span>ระดับ</span>
|
||||||
|
<span class="font-bold text-slate-900 dark:text-white">ทุกระดับ</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -463,3 +427,4 @@ const filteredCourses = computed(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -238,233 +238,142 @@ onBeforeUnmount(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="learning-shell font-main antialiased selection:bg-blue-500/30 dark:!bg-[#0F172A] dark:!text-slate-200 transition-colors">
|
<q-layout view="hHh LpR lFf" class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 font-inter">
|
||||||
<!-- Header: Custom top bar for learning context -->
|
|
||||||
<header class="learning-header px-2 md:px-4 h-14 md:h-[56px] border-b border-slate-200 dark:border-white/5 flex items-center justify-between gap-2 md:gap-4 dark:!bg-slate-800 transition-colors">
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-1 md:gap-6 min-w-0 flex-1">
|
<q-header bordered class="bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-white/5 text-slate-900 dark:text-white h-14">
|
||||||
<!-- Mobile Sidebar Toggle -->
|
<q-toolbar>
|
||||||
<button class="md:hidden text-slate-900 dark:text-white p-2 hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex-shrink-0 transition-colors" @click="toggleSidebar">
|
<q-btn flat round dense icon="menu" class="lg:hidden mr-2" @click="toggleSidebar" />
|
||||||
<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="M4 6h16M4 12h16M4 18h16" /></svg>
|
|
||||||
</button>
|
<q-btn flat dense no-caps icon="arrow_back" label="กลับไปหน้าหลัก" to="/dashboard/my-courses" class="text-slate-600 dark:text-slate-300 mobile-hide-label" />
|
||||||
<!-- Back Navigation -->
|
|
||||||
<NuxtLink to="/dashboard/my-courses" class="text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors flex items-center gap-2 flex-shrink-0 p-2 md:p-0">
|
<q-toolbar-title class="text-sm font-bold text-center lg:text-left truncate">
|
||||||
<span class="text-lg md:text-base">←</span>
|
{{ courseData ? getLocalizedText(courseData.course.title) : 'กำลังโหลด...' }}
|
||||||
<span class="hidden md:inline text-[11px] font-bold">กลับไปหน้าหลัก</span>
|
</q-toolbar-title>
|
||||||
</NuxtLink>
|
|
||||||
<div class="w-[1px] h-4 bg-slate-200 dark:bg-white/10 hidden md:block flex-shrink-0"/>
|
|
||||||
<h1 class="text-[13px] md:text-sm font-black text-slate-900 dark:text-white tracking-tight truncate min-w-0 pr-2">
|
|
||||||
{{ courseData ? getLocalizedText(courseData.course.title) : 'กำลังโหลด...' }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Header Actions (Progress) -->
|
|
||||||
<div class="flex items-center gap-2 md:gap-10 pr-2 md:pr-0">
|
|
||||||
<!-- Progress bar removed as it was static/mock data -->
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Sidebar: Course Curriculum list -->
|
<div class="flex items-center gap-2">
|
||||||
<aside class="learning-sidebar dark:!bg-gray-900 dark:!border-r-white/5 transition-colors" :class="{ 'open': sidebarOpen }">
|
<!-- Right actions -->
|
||||||
<div class="py-2" v-if="courseData">
|
</div>
|
||||||
<!-- Chapters & Lessons List -->
|
</q-toolbar>
|
||||||
<div v-for="chapter in courseData.chapters" :key="chapter.id" class="mt-4">
|
</q-header>
|
||||||
<div class="chapter-header px-4 py-2 dark:!bg-slate-900 dark:!text-white dark:!border-b-white/5">
|
|
||||||
{{ getLocalizedText(chapter.title) }}
|
<!-- Sidebar (Curriculum) -->
|
||||||
|
<q-drawer
|
||||||
|
v-model="sidebarOpen"
|
||||||
|
show-if-above
|
||||||
|
bordered
|
||||||
|
side="left"
|
||||||
|
:width="320"
|
||||||
|
:breakpoint="1024"
|
||||||
|
class="bg-white dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<div v-if="courseData" class="h-full scroll">
|
||||||
|
<q-list class="pb-10">
|
||||||
|
<template v-for="chapter in courseData.chapters" :key="chapter.id">
|
||||||
|
<q-item-label header class="bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-bold sticky top-0 z-10 border-b dark:border-white/5 text-xs py-3">
|
||||||
|
{{ getLocalizedText(chapter.title) }}
|
||||||
|
</q-item-label>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-for="lesson in chapter.lessons"
|
||||||
|
:key="lesson.id"
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
:active="currentLesson?.id === lesson.id"
|
||||||
|
active-class="bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-200 font-medium"
|
||||||
|
class="border-b border-gray-50 dark:border-white/5"
|
||||||
|
@click="!lesson.is_locked && handleLessonSelect(lesson.id)"
|
||||||
|
:disable="lesson.is_locked"
|
||||||
|
>
|
||||||
|
<q-item-section avatar v-if="lesson.is_locked">
|
||||||
|
<q-icon name="lock" size="xs" color="grey" />
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="text-xs md:text-sm line-clamp-2">
|
||||||
|
{{ getLocalizedText(lesson.title) }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon v-if="lesson.progress?.is_completed" name="check_circle" color="positive" size="xs" />
|
||||||
|
<q-icon v-else-if="currentLesson?.id === lesson.id" name="play_circle" color="primary" size="xs" />
|
||||||
|
<q-icon v-else name="radio_button_unchecked" color="grey-4" size="xs" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isLoading" class="p-6 text-center text-slate-500">
|
||||||
|
<q-spinner color="primary" size="2em" />
|
||||||
|
<div class="mt-2 text-xs">กำลังโหลดเนื้อหา...</div>
|
||||||
|
</div>
|
||||||
|
</q-drawer>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<q-page-container class="bg-white dark:bg-slate-900">
|
||||||
|
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]">
|
||||||
|
<!-- Video Player & Content Area -->
|
||||||
|
<!-- Note: Using existing logic but wrapped -->
|
||||||
|
<div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow">
|
||||||
|
<!-- Video Player Component Placeholder logic -->
|
||||||
|
<div v-if="currentLesson" class="bg-black rounded-xl overflow-hidden shadow-lg mb-6 aspect-video relative group">
|
||||||
|
<video
|
||||||
|
ref="videoRef"
|
||||||
|
v-if="videoSrc"
|
||||||
|
:src="videoSrc"
|
||||||
|
class="w-full h-full object-contain"
|
||||||
|
@click="togglePlay"
|
||||||
|
@timeupdate="updateProgress"
|
||||||
|
@loadedmetadata="onVideoMetadataLoaded"
|
||||||
|
@ended="onVideoEnded"
|
||||||
|
/>
|
||||||
|
<div v-else class="flex items-center justify-center h-full text-white/50 bg-slate-900">
|
||||||
|
<div class="text-center">
|
||||||
|
<q-icon name="article" size="xl" />
|
||||||
|
<p class="mt-2">บทเรียนนี้เป็นเอกสารประกอบการเรียน</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Controls Overlay (Simplified) -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent transition-opacity opacity-0 group-hover:opacity-100" v-if="videoSrc">
|
||||||
|
<div class="flex items-center gap-4 text-white">
|
||||||
|
<q-btn flat round dense :icon="isPlaying ? 'pause' : 'play_arrow'" @click.stop="togglePlay" />
|
||||||
|
<div class="relative flex-grow h-1 bg-white/30 rounded cursor-pointer" @click="seek">
|
||||||
|
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded" :style="{ width: videoProgress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-mono">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-for="lesson in chapter.lessons"
|
<!-- Lesson Info -->
|
||||||
:key="lesson.id"
|
<div v-if="currentLesson" class="bg-white dark:bg-slate-800 p-6 rounded-2xl shadow-sm border border-slate-100 dark:border-white/5">
|
||||||
class="lesson-item px-4 dark:!text-slate-300 dark:!border-b-white/5 hover:dark:!bg-white/5 hover:dark:!text-white transition-colors"
|
<h1 class="text-2xl font-bold mb-2">{{ getLocalizedText(currentLesson.title) }}</h1>
|
||||||
:class="{
|
<p class="text-slate-500 dark:text-slate-400" v-if="currentLesson.description">{{ currentLesson.description }}</p>
|
||||||
'active-lesson': currentLesson?.id === lesson.id,
|
<div class="mt-6 prose dark:prose-invert max-w-none">
|
||||||
'completed': lesson.progress?.is_completed,
|
<!-- Content description or attachments here -->
|
||||||
'locked': lesson.is_locked
|
<div v-if="!videoSrc" class="p-4 bg-slate-50 dark:bg-slate-900/50 rounded-xl border border-dashed border-slate-300 dark:border-slate-700 text-center">
|
||||||
}"
|
<p>เนื้อหาบทเรียน</p>
|
||||||
@click="!lesson.is_locked && handleLessonSelect(lesson.id)"
|
</div>
|
||||||
>
|
</div>
|
||||||
<div class="flex items-center gap-2 overflow-hidden">
|
|
||||||
<span class="flex-shrink-0 text-slate-400 text-xs" v-if="lesson.is_locked">🔒</span>
|
|
||||||
<span class="text-[12px] font-medium tracking-tight truncate pr-4">
|
|
||||||
{{ getLocalizedText(lesson.title) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="icon-status flex-shrink-0" :class="{ 'text-emerald-500': lesson.progress?.is_completed, 'text-blue-500': currentLesson?.id === lesson.id }">
|
|
||||||
<span v-if="lesson.progress?.is_completed">✓</span>
|
|
||||||
<span v-else-if="currentLesson?.id === lesson.id">▶</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</q-page>
|
||||||
<div v-else-if="isLoading" class="p-6 text-center text-slate-500 text-sm">
|
</q-page-container>
|
||||||
กำลังโหลดเนื้อหา...
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Sidebar Overlay for mobile -->
|
</q-layout>
|
||||||
<div v-if="sidebarOpen" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[85] md:hidden" @click="toggleSidebar"/>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
.learning-shell {
|
.mobile-hide-label .q-btn__content span {
|
||||||
display: grid;
|
display: none;
|
||||||
grid-template-columns: 320px 1fr;
|
|
||||||
grid-template-rows: 56px 1fr;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #1e293b;
|
|
||||||
transition: background 0.2s, color 0.2s;
|
|
||||||
}
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
:global(.dark) .learning-shell {
|
.mobile-hide-label .q-btn__content span {
|
||||||
background: #0F172A;
|
display: inline;
|
||||||
color: #F8FAFC;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.learning-header {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
background: #ffffff;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
z-index: 100;
|
|
||||||
transition: background 0.2s, border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .learning-header {
|
|
||||||
background: #1e293b;
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.learning-sidebar {
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 1;
|
|
||||||
background: #ffffff;
|
|
||||||
border-right: 1px solid #e2e8f0;
|
|
||||||
overflow-y: auto;
|
|
||||||
z-index: 90;
|
|
||||||
transition: background 0.2s, border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .learning-sidebar {
|
|
||||||
background: #111827;
|
|
||||||
border-right-color: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-container {
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 2;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #ffffff;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .main-container {
|
|
||||||
background: #0B0F1A;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.main-container {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lesson-item {
|
|
||||||
padding: 14px 24px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
color: #000000;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
.lesson-item:hover {
|
|
||||||
background-color: #f1f5f9;
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .lesson-item {
|
|
||||||
color: #e2e8f0;
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .lesson-item:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.03);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-tab {
|
|
||||||
background: rgba(59, 130, 246, 0.1);
|
|
||||||
color: #2563eb;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.active-lesson {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
color: #000000;
|
|
||||||
box-shadow: inset 4px 0 0 #3B82F6;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .active-lesson {
|
|
||||||
background-color: #1E293B;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-header {
|
|
||||||
background: #f3f4f6;
|
|
||||||
color: #000000;
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: 13px;
|
|
||||||
border-bottom: 1px solid #d1d5db;
|
|
||||||
transition: all 0.2s;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .chapter-header {
|
|
||||||
background: #0F172A;
|
|
||||||
color: #ffffff;
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.learning-footer {
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fadeIn 0.4s ease-out forwards;
|
|
||||||
}
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.learning-shell { grid-template-columns: 1fr; }
|
|
||||||
.learning-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
left: -320px;
|
|
||||||
top: 56px;
|
|
||||||
bottom: 0;
|
|
||||||
width: 320px;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
.learning-sidebar.open { transform: translateX(320px); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const route = useRoute()
|
||||||
const showEnrollModal = ref(false)
|
const showEnrollModal = ref(false)
|
||||||
const showCertModal = ref(false)
|
const showCertModal = ref(false)
|
||||||
const activeFilter = ref<'all' | 'progress' | 'completed'>('all')
|
const activeFilter = ref<'all' | 'progress' | 'completed'>('all')
|
||||||
|
const { currentUser } = useAuth()
|
||||||
|
|
||||||
// Check URL query parameters to show 'Enrollment Success' modal
|
// Check URL query parameters to show 'Enrollment Success' modal
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
@ -42,32 +43,23 @@ const isLoading = ref(false)
|
||||||
const loadEnrolledCourses = async () => {
|
const loadEnrolledCourses = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
const apiStatus = activeFilter.value === 'all'
|
const apiStatus = activeFilter.value === 'all'
|
||||||
? undefined // No status param = all
|
? undefined
|
||||||
: activeFilter.value === 'completed'
|
: activeFilter.value === 'completed'
|
||||||
? 'COMPLETED'
|
? 'COMPLETED'
|
||||||
: 'IN_PROGRESS' // 'progress' -> IN_PROGRESS
|
: 'IN_PROGRESS'
|
||||||
|
|
||||||
// Actually, 'all' in UI might mean showing everything locally, OR fetching everything.
|
|
||||||
// The API supports status filter. Let's use clean fetching for simplicity and accuracy.
|
|
||||||
// Although checking the previous mock, 'all' showed both.
|
|
||||||
// If we want 'all' to show everything, we should pass undefined status.
|
|
||||||
// If we filter client side, we fetch ALL first.
|
|
||||||
|
|
||||||
// Strategy: Fetch based on filter.
|
|
||||||
// If 'all', do not send status.
|
|
||||||
const res = await fetchEnrolledCourses({
|
const res = await fetchEnrolledCourses({
|
||||||
status: activeFilter.value === 'all' ? undefined : (activeFilter.value === 'completed' ? 'COMPLETED' : 'IN_PROGRESS')
|
status: activeFilter.value === 'all' ? undefined : (activeFilter.value === 'completed' ? 'COMPLETED' : 'IN_PROGRESS')
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
enrolledCourses.value = (res.data || []).map(item => ({
|
enrolledCourses.value = (res.data || []).map(item => ({
|
||||||
id: item.course_id, // CourseCard might expect course id for links, or enrollment id? Usually CourseCard links to course detail.
|
id: item.course_id,
|
||||||
// Wait, CourseCard likely needs course ID to link to /classroom/learning/:id
|
|
||||||
enrollment_id: item.id,
|
enrollment_id: item.id,
|
||||||
title: getLocalizedText(item.course.title),
|
title: getLocalizedText(item.course.title),
|
||||||
progress: 0, // item.progress_percentage (Forced to 0 as requested)
|
progress: 0,
|
||||||
completed: item.status === 'COMPLETED',
|
completed: item.status === 'COMPLETED',
|
||||||
thumbnail_url: item.course.thumbnail_url // CourseCard might need this
|
thumbnail_url: item.course.thumbnail_url
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|
@ -93,43 +85,36 @@ const downloadCertificate = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="p-8 max-w-6xl mx-auto">
|
||||||
|
|
||||||
|
|
||||||
<!-- Page Header & Filters -->
|
<!-- Page Header & Filters -->
|
||||||
<div class="flex justify-between items-center mb-6 mobile-stack">
|
<div class="flex flex-col items-start mb-12">
|
||||||
<h1 class="text-[28px] font-bold text-slate-900 dark:text-white">{{ $t('sidebar.myCourses') }}</h1>
|
<h1 class="text-3xl font-extrabold text-slate-900 dark:text-white mb-8">{{ $t('sidebar.myCourses') }}</h1>
|
||||||
<!-- Filter Tabs -->
|
|
||||||
<div class="flex gap-2" style="overflow-x: auto; padding-bottom: 4px; width: 100%; justify-content: flex-start;">
|
<!-- Filter Tabs -->
|
||||||
<button
|
<div class="flex gap-2">
|
||||||
:class="activeFilter === 'all' ? 'btn btn-primary' : 'btn btn-secondary'"
|
<button
|
||||||
style="white-space: nowrap;"
|
v-for="filter in ['all', 'progress', 'completed']"
|
||||||
@click="activeFilter = 'all'"
|
:key="filter"
|
||||||
>
|
@click="activeFilter = filter as any"
|
||||||
{{ $t('myCourses.filterAll') }}
|
class="px-8 py-2.5 rounded-full text-base font-bold transition-all"
|
||||||
</button>
|
:class="activeFilter === filter
|
||||||
<button
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-500/30'
|
||||||
:class="activeFilter === 'progress' ? 'btn btn-primary' : 'btn btn-secondary'"
|
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800'"
|
||||||
style="white-space: nowrap;"
|
>
|
||||||
@click="activeFilter = 'progress'"
|
{{ $t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`) }}
|
||||||
>
|
</button>
|
||||||
{{ $t('myCourses.filterProgress') }}
|
</div>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="activeFilter === 'completed' ? 'btn btn-primary' : 'btn btn-secondary'"
|
|
||||||
style="white-space: nowrap;"
|
|
||||||
@click="activeFilter = 'completed'"
|
|
||||||
>
|
|
||||||
{{ $t('myCourses.filterCompleted') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Courses Grid -->
|
<!-- Courses Grid -->
|
||||||
<div v-if="isLoading" class="flex justify-center py-20">
|
<div v-if="isLoading" class="flex justify-center py-20">
|
||||||
<div class="spinner-border animate-spin inline-block w-8 h-8 border-4 rounded-full" role="status">
|
<div class="spinner-border animate-spin inline-block w-8 h-8 border-4 border-blue-500 rounded-full border-t-transparent" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="my-courses-grid">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
<template v-for="course in enrolledCourses" :key="course.id">
|
<template v-for="course in enrolledCourses" :key="course.id">
|
||||||
<!-- In Progress Course Card -->
|
<!-- In Progress Course Card -->
|
||||||
<CourseCard
|
<CourseCard
|
||||||
|
|
@ -156,27 +141,32 @@ const downloadCertificate = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="!isLoading && enrolledCourses.length === 0" class="empty-state">
|
<div v-if="!isLoading && enrolledCourses.length === 0" class="flex flex-col items-center justify-center py-20 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 mt-4">
|
||||||
<div class="empty-state-icon">📚</div>
|
<div class="text-6xl mb-4">📚</div>
|
||||||
<h3 class="empty-state-title">{{ $t('myCourses.emptyTitle') }}</h3>
|
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('myCourses.emptyTitle') }}</h3>
|
||||||
<p class="empty-state-description">{{ $t('myCourses.emptyDesc') }}</p>
|
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t('myCourses.emptyDesc') }}</p>
|
||||||
<NuxtLink to="/browse/discovery" class="btn btn-primary">{{ $t('myCourses.goToDiscovery') }}</NuxtLink>
|
<NuxtLink to="/browse/discovery" class="mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">{{ $t('myCourses.goToDiscovery') }}</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MODAL: Enrollment Success -->
|
<!-- MODAL: Enrollment Success -->
|
||||||
<div
|
<div
|
||||||
v-if="showEnrollModal"
|
v-if="showEnrollModal"
|
||||||
style="display: flex; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 100; align-items: center; justify-content: center; padding: 20px;"
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<div class="card" style="width: 400px; text-align: center; max-width: 90%;">
|
<div class="bg-white dark:bg-slate-800 rounded-3xl shadow-2xl p-8 max-w-sm w-full text-center relative overflow-hidden">
|
||||||
<div style="width: 64px; height: 64px; background: var(--success); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 32px; margin: 0 auto 24px;">
|
<div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-green-400 to-emerald-600"></div>
|
||||||
|
<div class="w-16 h-16 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-full flex items-center justify-center text-3xl mx-auto mb-6">
|
||||||
✓
|
✓
|
||||||
</div>
|
</div>
|
||||||
<h2 class="font-bold mb-2">{{ $t('enrollment.successTitle') }}</h2>
|
<h2 class="text-2xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('enrollment.successTitle') }}</h2>
|
||||||
<p class="text-muted mb-6">{{ $t('enrollment.successDesc') }}</p>
|
<p class="text-slate-500 dark:text-slate-400 mb-8">{{ $t('enrollment.successDesc') }}</p>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-3">
|
||||||
<NuxtLink :to="`/classroom/learning?course_id=${route.query.course_id}`" class="btn btn-primary w-full">{{ $t('enrollment.startNow') }}</NuxtLink>
|
<NuxtLink :to="`/classroom/learning?course_id=${route.query.course_id}`" class="w-full py-3 bg-blue-600 text-white rounded-xl font-bold hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/30">
|
||||||
<button class="btn btn-secondary w-full" @click="showEnrollModal = false">{{ $t('enrollment.later') }}</button>
|
{{ $t('enrollment.startNow') }}
|
||||||
|
</NuxtLink>
|
||||||
|
<button class="w-full py-3 text-slate-500 font-bold hover:bg-slate-50 dark:hover:bg-slate-700/50 rounded-xl transition-colors" @click="showEnrollModal = false">
|
||||||
|
{{ $t('enrollment.later') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,49 +174,52 @@ const downloadCertificate = () => {
|
||||||
<!-- MODAL: Certificate Preview -->
|
<!-- MODAL: Certificate Preview -->
|
||||||
<div
|
<div
|
||||||
v-if="showCertModal"
|
v-if="showCertModal"
|
||||||
style="display: flex; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; align-items: center; justify-content: center; padding: 20px;"
|
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<div class="cert-container">
|
<div class="relative bg-white text-slate-900 w-full max-w-2xl aspect-[1.414/1] shadow-2xl rounded-sm p-12 flex flex-col items-center text-center overflow-hidden">
|
||||||
<!-- Close Button -->
|
<!-- Close Button -->
|
||||||
<button style="position: absolute; top: 15px; right: 20px; border: none; background: none; font-size: 32px; cursor: pointer; color: #1E293B; z-index: 10;" @click="showCertModal = false">×</button>
|
<button class="absolute top-4 right-4 text-slate-400 hover:text-slate-600 text-3xl leading-none" @click="showCertModal = false">×</button>
|
||||||
|
|
||||||
<div class="cert-inner">
|
<!-- Border Decoration -->
|
||||||
<h1 class="cert-title">{{ $t('certificate.title') }}</h1>
|
<div class="absolute inset-4 border-4 border-double border-slate-200 pointer-events-none"></div>
|
||||||
<div style="width: 100px; height: 2px; background: #D4AF37; margin: 0 auto 24px;"/>
|
|
||||||
|
|
||||||
<p style="color: #64748B; margin-bottom: 16px; font-size: 16px;">{{ $t('certificate.presentedTo') }}</p>
|
|
||||||
|
|
||||||
<h2 class="cert-name">{{ $t('userMenu.home') === 'Home' ? 'Somchai Jaidee' : 'สมชาย ใจดี' }}</h2>
|
|
||||||
|
|
||||||
<p style="color: #64748B; margin-bottom: 16px; font-size: 16px;">{{ $t('certificate.completedDesc') }}</p>
|
|
||||||
|
|
||||||
<h3 style="font-size: 24px; font-weight: 700; color: #3B82F6; margin-bottom: 30px;">HTML5 พื้นฐาน</h3>
|
|
||||||
|
|
||||||
<!-- Signature Section -->
|
<div class="relative z-10 w-full h-full flex flex-col justify-center">
|
||||||
<div class="cert-footer">
|
<div class="w-16 h-16 mx-auto mb-6 bg-blue-600 text-white flex items-center justify-center rounded-full font-serif font-bold text-3xl">E</div>
|
||||||
<div style="text-align: center;">
|
|
||||||
<div style="width: 150px; border-bottom: 1px solid #1E293B; margin-bottom: 8px; padding-bottom: 8px; font-style: italic; margin-left: auto; margin-right: auto;">Somchai K.</div>
|
<h1 class="text-4xl font-serif font-bold mb-2 uppercase tracking-widest text-slate-800">{{ $t('certificate.title') }}</h1>
|
||||||
<div style="font-size: 12px; color: #64748B;">{{ $t('certificate.directorSignature') }}</div>
|
<div class="w-32 h-1 bg-amber-400 mx-auto mb-8"></div>
|
||||||
|
|
||||||
|
<p class="text-slate-500 mb-4 text-lg italic font-serif">{{ $t('certificate.presentedTo') }}</p>
|
||||||
|
|
||||||
|
<h2 class="text-5xl font-serif font-bold text-blue-900 mb-6 font-handwriting">{{ currentUser?.firstName }} {{ currentUser?.lastName }}</h2>
|
||||||
|
|
||||||
|
<p class="text-slate-500 mb-6 text-lg italic font-serif">{{ $t('certificate.completedDesc') }}</p>
|
||||||
|
|
||||||
|
<h3 class="text-2xl font-bold text-slate-800 mb-12">HTML5 Fundamentals</h3>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-end px-12 mt-auto">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-48 border-b border-slate-800 mb-2 pb-2 italic font-serif text-lg">Somchai K.</div>
|
||||||
|
<div class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('certificate.directorSignature') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Golden Seal -->
|
<div class="w-24 h-24 bg-gradient-to-br from-amber-300 to-amber-600 rounded-full flex flex-col items-center justify-center text-white border-4 border-white shadow-xl rotate-12 -mt-4">
|
||||||
<div style="width: 80px; height: 80px; background: #D4AF37; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: white; border: 4px double white; box-shadow: 0 0 0 4px #D4AF37; transform: rotate(-5deg); flex-shrink: 0;">
|
<span class="text-[10px] uppercase font-bold tracking-widest opacity-80">Certified</span>
|
||||||
<div style="font-size: 10px; font-weight: bold;">Certified</div>
|
<span class="font-black text-xl">PASS</span>
|
||||||
<div style="font-size: 16px; font-weight: 900;">{{ $t('certificate.passed') }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="text-align: center;">
|
<div class="text-center">
|
||||||
<div style="width: 150px; border-bottom: 1px solid #1E293B; margin-bottom: 8px; padding-bottom: 8px; margin-left: auto; margin-right: auto;">15/12/2026</div>
|
<div class="w-48 border-b border-slate-800 mb-2 pb-2">{{ new Date().toLocaleDateString() }}</div>
|
||||||
<div style="font-size: 12px; color: #64748B;">{{ $t('certificate.issueDate') }}</div>
|
<div class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('certificate.issueDate') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Download Button -->
|
|
||||||
<div style="margin-top: 32px; text-align: center;">
|
<div class="absolute bottom-6 left-0 w-full text-center">
|
||||||
<button class="btn btn-primary" @click="downloadCertificate">
|
<button class="bg-blue-600 text-white px-6 py-2 rounded-full shadow-lg hover:bg-blue-700 font-bold transition-transform hover:scale-105 active:scale-95 flex items-center gap-2 mx-auto" @click="downloadCertificate">
|
||||||
⬇ {{ $t('certificate.downloadPDF') }}
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||||
</button>
|
{{ $t('certificate.downloadPDF') }}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -234,15 +227,8 @@ const downloadCertificate = () => {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.my-courses-grid {
|
/* Custom Font for Signature/Name if desired */
|
||||||
display: grid;
|
.font-handwriting {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
font-family: 'Dancing Script', cursive, serif; /* Fallback */
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.my-courses-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue