All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 47s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
331 lines
9.7 KiB
Vue
331 lines
9.7 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* @file discovery.vue
|
|
* @description Course Discovery / Catalog Page.
|
|
* Allows users to browse, filter, and view details of available courses.
|
|
* Includes a toggleable detailed view for course previews.
|
|
*/
|
|
|
|
definePageMeta({
|
|
layout: "default",
|
|
middleware: "auth",
|
|
});
|
|
|
|
useHead({
|
|
title: "รายการคอร์ส - e-Learning",
|
|
});
|
|
|
|
// ==========================================
|
|
// 1. State Management
|
|
const showDetail = ref(false);
|
|
const searchQuery = ref("");
|
|
const selectedCategoryIds = ref<number[]>([]);
|
|
const categories = ref<any[]>([]);
|
|
const courses = ref<any[]>([]);
|
|
const selectedCourse = ref<any>(null);
|
|
|
|
const isLoading = ref(false);
|
|
const isLoadingDetail = ref(false);
|
|
const isEnrolling = ref(false);
|
|
|
|
// Pagination State
|
|
const currentPage = ref(1);
|
|
const totalPages = ref(1);
|
|
const itemsPerPage = 12;
|
|
|
|
const { t, locale } = useI18n();
|
|
const { currentUser } = useAuth();
|
|
const $q = useQuasar();
|
|
const { fetchCategories } = useCategory();
|
|
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } =
|
|
useCourse();
|
|
|
|
// 2. Computed Properties
|
|
const sortOption = ref(t("discovery.sortRecent"));
|
|
const sortOptions = computed(() => [t("discovery.sortRecent")]);
|
|
|
|
const filteredCourses = computed(() => {
|
|
let result = courses.value;
|
|
|
|
// If more than 1 category is selected, we still do client-side filtering
|
|
// because the API currently only supports one category_id at a time.
|
|
if (selectedCategoryIds.value.length > 1) {
|
|
result = result.filter((c) =>
|
|
selectedCategoryIds.value.includes(c.category_id),
|
|
);
|
|
}
|
|
|
|
if (searchQuery.value) {
|
|
const query = searchQuery.value.toLowerCase();
|
|
result = result.filter((c) => {
|
|
const title = getLocalizedText(c.title).toLowerCase();
|
|
const desc = getLocalizedText(c.description).toLowerCase();
|
|
return title.includes(query) || (desc && desc.includes(query));
|
|
});
|
|
}
|
|
return result;
|
|
});
|
|
|
|
// 3. Helper Functions
|
|
|
|
// 4. API Actions
|
|
const loadCategories = async () => {
|
|
const res = await fetchCategories();
|
|
if (res.success) categories.value = res.data || [];
|
|
};
|
|
|
|
const loadCourses = async (page = 1) => {
|
|
isLoading.value = true;
|
|
|
|
// Use server-side filtering if exactly one category is selected
|
|
const categoryId =
|
|
selectedCategoryIds.value.length === 1
|
|
? selectedCategoryIds.value[0]
|
|
: undefined;
|
|
|
|
const res = await fetchCourses({
|
|
category_id: categoryId,
|
|
page: page,
|
|
limit: itemsPerPage,
|
|
forceRefresh: true,
|
|
});
|
|
|
|
if (res.success) {
|
|
courses.value = res.data || [];
|
|
totalPages.value = res.totalPages || 1;
|
|
currentPage.value = res.page || 1;
|
|
}
|
|
isLoading.value = false;
|
|
};
|
|
|
|
const selectCourse = async (id: number) => {
|
|
isLoadingDetail.value = true;
|
|
selectedCourse.value = null;
|
|
showDetail.value = true;
|
|
const res = await fetchCourseById(id);
|
|
if (res.success) selectedCourse.value = res.data;
|
|
isLoadingDetail.value = false;
|
|
};
|
|
|
|
const handleEnroll = async (id: number) => {
|
|
if (isEnrolling.value) return;
|
|
isEnrolling.value = true;
|
|
const res = await enrollCourse(id);
|
|
if (res.success) {
|
|
return navigateTo("/dashboard/my-courses?enrolled=true");
|
|
} else {
|
|
$q.notify({
|
|
type: "negative",
|
|
message: res.error || t("enrollment.error"),
|
|
position: "top",
|
|
timeout: 3000,
|
|
actions: [{ icon: "close", color: "white" }],
|
|
});
|
|
}
|
|
isEnrolling.value = false;
|
|
};
|
|
|
|
// Watch for category selection changes to reload courses
|
|
watch(
|
|
selectedCategoryIds,
|
|
() => {
|
|
currentPage.value = 1;
|
|
loadCourses(1);
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
const toggleCategory = (id: number) => {
|
|
const index = selectedCategoryIds.value.indexOf(id);
|
|
if (index === -1) {
|
|
selectedCategoryIds.value.push(id);
|
|
} else {
|
|
selectedCategoryIds.value.splice(index, 1);
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadCategories();
|
|
loadCourses();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page-container">
|
|
<!-- CATALOG VIEW: Browse courses -->
|
|
<div v-if="!showDetail">
|
|
<!-- Top Header Area -->
|
|
<div class="flex flex-col gap-6 mb-10">
|
|
<div class="flex items-start gap-4 mb-4">
|
|
<span
|
|
class="w-1.5 h-10 md:h-12 bg-blue-600 rounded-full shadow-lg shadow-blue-500/50 mt-1 flex-shrink-0"
|
|
></span>
|
|
<div>
|
|
<h1
|
|
class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight"
|
|
>
|
|
{{ $t("discovery.title") }}
|
|
</h1>
|
|
<p
|
|
v-if="filteredCourses.length > 0"
|
|
class="text-slate-500 dark:text-slate-400 mt-1 font-medium"
|
|
>
|
|
{{ $t("discovery.foundTotal") }}
|
|
<span class="text-blue-600 font-bold leading-none">{{
|
|
filteredCourses.length
|
|
}}</span>
|
|
{{ $t("discovery.items") }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Unified Filter Section: Categories -->
|
|
<div
|
|
class="bg-white dark:bg-slate-900/50 p-2 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex flex-wrap items-center gap-1.5 shadow-sm"
|
|
>
|
|
<q-btn
|
|
flat
|
|
rounded
|
|
dense
|
|
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
|
|
:class="
|
|
selectedCategoryIds.length === 0
|
|
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
|
|
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
|
|
"
|
|
@click="selectedCategoryIds = []"
|
|
:label="$t('discovery.showAll')"
|
|
/>
|
|
<q-btn
|
|
v-for="cat in categories"
|
|
:key="cat.id"
|
|
flat
|
|
rounded
|
|
dense
|
|
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
|
|
:class="
|
|
selectedCategoryIds.includes(cat.id)
|
|
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
|
|
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
|
|
"
|
|
@click="toggleCategory(cat.id)"
|
|
:label="getLocalizedText(cat.name)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Layout: Grid Only -->
|
|
<div class="w-full">
|
|
<div v-if="filteredCourses.length > 0" class="flex flex-col gap-12">
|
|
<div
|
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
|
|
>
|
|
<CourseCard
|
|
v-for="course in filteredCourses"
|
|
:key="course.id"
|
|
v-bind="{ ...course, image: course.thumbnail_url }"
|
|
show-view-details
|
|
@view-details="selectCourse(course.id)"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Pagination Controls -->
|
|
<div v-if="totalPages > 1" class="flex justify-center pb-10">
|
|
<q-pagination
|
|
v-model="currentPage"
|
|
:max="totalPages"
|
|
:max-pages="6"
|
|
boundary-numbers
|
|
direction-links
|
|
color="primary"
|
|
flat
|
|
active-design="unelevated"
|
|
active-color="primary"
|
|
@update:model-value="loadCourses"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div
|
|
v-else
|
|
class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 shadow-sm"
|
|
>
|
|
<q-icon
|
|
name="search_off"
|
|
size="64px"
|
|
class="text-slate-300 dark:text-slate-600 mb-4"
|
|
/>
|
|
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">
|
|
{{ $t("discovery.emptyTitle") }}
|
|
</h3>
|
|
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
|
|
{{ $t("discovery.emptyDesc") }}
|
|
</p>
|
|
<button
|
|
class="mt-6 font-bold text-blue-600 hover:text-blue-700 dark:hover:text-blue-400 transition-colors"
|
|
@click="
|
|
searchQuery = '';
|
|
selectedCategoryIds = [];
|
|
"
|
|
>
|
|
{{ $t("discovery.showAll") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
|
|
<div v-else>
|
|
<button
|
|
@click="showDetail = false"
|
|
class="inline-flex items-center gap-2 text-slate-600 dark:text-white hover:text-blue-600 dark:hover:text-blue-300 mb-6 transition-all font-black text-lg md:text-xl group"
|
|
>
|
|
<q-icon
|
|
name="arrow_back"
|
|
size="24px"
|
|
class="transition-transform group-hover:-translate-x-1"
|
|
/>
|
|
{{ $t("discovery.backToCatalog") }}
|
|
</button>
|
|
|
|
<div v-if="isLoadingDetail" class="flex justify-center py-20">
|
|
<q-spinner size="3rem" color="primary" />
|
|
</div>
|
|
|
|
<CourseDetailView
|
|
v-else-if="selectedCourse"
|
|
:course="selectedCourse"
|
|
:user="currentUser"
|
|
@back="showDetail = false"
|
|
@enroll="handleEnroll"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Standard overrides for Quasar inputs to match Tailwind theme */
|
|
.search-input :deep(.q-field__control) {
|
|
border-radius: 9999px; /* Full rounded pill */
|
|
background-color: white !important;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.dark .search-input :deep(.q-field__control) {
|
|
background-color: #1e293b !important; /* slate-800: Inner card depth */
|
|
border-color: rgba(255, 255, 255, 0.1) !important;
|
|
}
|
|
|
|
.search-input :deep(.q-field__native) {
|
|
color: #0f172a !important; /* slate-900: Dark text for light mode */
|
|
}
|
|
|
|
.dark .search-input :deep(.q-field__native) {
|
|
color: white !important;
|
|
}
|
|
|
|
.search-input :deep(.q-field__shadow) {
|
|
box-shadow: none !important;
|
|
}
|
|
</style>
|