feat: Add initial pages and components for user dashboard, profile, course discovery, and classroom learning with i18n support.
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
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
This commit is contained in:
parent
f26a94076c
commit
e3873f616e
11 changed files with 1046 additions and 685 deletions
|
|
@ -33,29 +33,31 @@ 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();
|
||||
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } =
|
||||
useCourse();
|
||||
|
||||
// 2. Computed Properties
|
||||
const sortOption = ref(t('discovery.sortRecent'));
|
||||
const sortOptions = computed(() => [t('discovery.sortRecent')]);
|
||||
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
|
||||
|
||||
// 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));
|
||||
result = result.filter((c) =>
|
||||
selectedCategoryIds.value.includes(c.category_id),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
result = result.filter(c => {
|
||||
result = result.filter((c) => {
|
||||
const title = getLocalizedText(c.title).toLowerCase();
|
||||
const desc = getLocalizedText(c.description).toLowerCase();
|
||||
return title.includes(query) || (desc && desc.includes(query));
|
||||
|
|
@ -66,7 +68,6 @@ const filteredCourses = computed(() => {
|
|||
|
||||
// 3. Helper Functions
|
||||
|
||||
|
||||
// 4. API Actions
|
||||
const loadCategories = async () => {
|
||||
const res = await fetchCategories();
|
||||
|
|
@ -75,17 +76,20 @@ const loadCategories = async () => {
|
|||
|
||||
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 categoryId =
|
||||
selectedCategoryIds.value.length === 1
|
||||
? selectedCategoryIds.value[0]
|
||||
: undefined;
|
||||
|
||||
const res = await fetchCourses({
|
||||
category_id: categoryId,
|
||||
page: page,
|
||||
limit: itemsPerPage,
|
||||
forceRefresh: true
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
|
||||
if (res.success) {
|
||||
courses.value = res.data || [];
|
||||
totalPages.value = res.totalPages || 1;
|
||||
|
|
@ -108,33 +112,37 @@ const handleEnroll = async (id: number) => {
|
|||
isEnrolling.value = true;
|
||||
const res = await enrollCourse(id);
|
||||
if (res.success) {
|
||||
return navigateTo('/dashboard/my-courses?enrolled=true');
|
||||
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' }]
|
||||
})
|
||||
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 });
|
||||
watch(
|
||||
selectedCategoryIds,
|
||||
() => {
|
||||
currentPage.value = 1;
|
||||
loadCourses(1);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const toggleCategory = (id: number) => {
|
||||
const index = selectedCategoryIds.value.indexOf(id)
|
||||
const index = selectedCategoryIds.value.indexOf(id);
|
||||
if (index === -1) {
|
||||
selectedCategoryIds.value.push(id)
|
||||
selectedCategoryIds.value.push(id);
|
||||
} else {
|
||||
selectedCategoryIds.value.splice(index, 1)
|
||||
selectedCategoryIds.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories();
|
||||
|
|
@ -144,109 +152,145 @@ onMounted(() => {
|
|||
|
||||
<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>
|
||||
<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
|
||||
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">
|
||||
พบทั้งหมด <span class="text-blue-600 font-bold leading-none">{{ filteredCourses.length }}</span> รายการ
|
||||
<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
|
||||
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-if="filteredCourses.length > 0" class="flex flex-col gap-12">
|
||||
<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"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</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"
|
||||
<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') }}
|
||||
<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" />
|
||||
<q-spinner size="3rem" color="primary" />
|
||||
</div>
|
||||
|
||||
<CourseDetailView
|
||||
|
|
@ -285,4 +329,3 @@ onMounted(() => {
|
|||
box-shadow: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue