2026-01-13 10:46:40 +07:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
/**
|
2026-02-19 10:39:44 +07:00
|
|
|
* @file index.vue
|
|
|
|
|
* @description Dashboard Home Page matching FutureSkill design
|
2026-01-13 10:46:40 +07:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
definePageMeta({
|
2026-02-20 14:58:18 +07:00
|
|
|
layout: "default",
|
|
|
|
|
middleware: "auth",
|
|
|
|
|
});
|
2026-01-13 10:46:40 +07:00
|
|
|
|
|
|
|
|
useHead({
|
2026-02-20 14:58:18 +07:00
|
|
|
title: "Dashboard - FutureSkill Clone",
|
|
|
|
|
});
|
2026-01-13 10:46:40 +07:00
|
|
|
|
2026-02-20 14:58:18 +07:00
|
|
|
const { currentUser } = useAuth();
|
|
|
|
|
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse();
|
|
|
|
|
const { fetchCategories } = useCategory();
|
|
|
|
|
const { t } = useI18n();
|
2026-02-09 10:53:42 +07:00
|
|
|
|
2026-02-19 10:39:44 +07:00
|
|
|
// State
|
2026-02-20 14:58:18 +07:00
|
|
|
const enrolledCourses = ref<any[]>([]);
|
|
|
|
|
const recommendedCourses = ref<any[]>([]);
|
|
|
|
|
const libraryCourses = ref<any[]>([]);
|
|
|
|
|
const categories = ref<any[]>([]);
|
2026-02-19 10:39:44 +07:00
|
|
|
|
2026-02-20 14:58:18 +07:00
|
|
|
const isLoading = ref(true);
|
2026-01-20 16:46:19 +07:00
|
|
|
|
2026-02-19 10:39:44 +07:00
|
|
|
// Initial Data Fetch
|
2026-01-20 16:46:19 +07:00
|
|
|
onMounted(async () => {
|
2026-02-20 14:58:18 +07:00
|
|
|
isLoading.value = true;
|
2026-02-19 10:39:44 +07:00
|
|
|
try {
|
|
|
|
|
const [catRes, enrollRes, courseRes] = await Promise.all([
|
|
|
|
|
fetchCategories(),
|
2026-02-19 17:37:28 +07:00
|
|
|
fetchEnrolledCourses({ limit: 10 }), // Fetch more enrolled courses for library section
|
2026-02-20 14:58:18 +07:00
|
|
|
fetchCourses({
|
|
|
|
|
limit: 3,
|
|
|
|
|
random: true,
|
|
|
|
|
forceRefresh: true,
|
|
|
|
|
is_recommended: true,
|
|
|
|
|
}), // Fetch 3 Recommended Courses
|
|
|
|
|
]);
|
2026-02-19 10:39:44 +07:00
|
|
|
|
|
|
|
|
if (catRes.success) {
|
2026-02-20 14:58:18 +07:00
|
|
|
categories.value = catRes.data || [];
|
2026-02-19 10:39:44 +07:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 14:58:18 +07:00
|
|
|
const catMap = new Map();
|
|
|
|
|
categories.value.forEach((c: any) => catMap.set(c.id, c.name));
|
|
|
|
|
|
2026-02-19 10:39:44 +07:00
|
|
|
// Map Enrolled Courses
|
|
|
|
|
if (enrollRes.success && enrollRes.data) {
|
2026-02-20 14:58:18 +07:00
|
|
|
// Sort by last_accessed_at descending (Newest first)
|
|
|
|
|
const sortedEnrollments = [...enrollRes.data].sort((a, b) => {
|
|
|
|
|
const dateA = new Date(a.last_accessed_at || a.enrolled_at).getTime();
|
|
|
|
|
const dateB = new Date(b.last_accessed_at || b.enrolled_at).getTime();
|
|
|
|
|
return dateB - dateA;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
enrolledCourses.value = sortedEnrollments.map((item: any) => ({
|
|
|
|
|
id: item.course_id,
|
|
|
|
|
title: item.course.title,
|
|
|
|
|
thumbnail_url: item.course.thumbnail_url,
|
|
|
|
|
progress: item.progress_percentage || 0,
|
|
|
|
|
total_lessons: item.course.total_lessons || 10,
|
|
|
|
|
completed_lessons: Math.floor(
|
|
|
|
|
(item.progress_percentage / 100) * (item.course.total_lessons || 10),
|
|
|
|
|
),
|
|
|
|
|
// For CourseCard compatibility in library section
|
|
|
|
|
category: catMap.get(item.course.category_id),
|
|
|
|
|
lessons: item.course.total_lessons || 0,
|
|
|
|
|
image: item.course.thumbnail_url,
|
|
|
|
|
enrolled: true,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Update libraryCourses with only 2 courses
|
|
|
|
|
libraryCourses.value = enrolledCourses.value.slice(0, 2);
|
2026-02-19 10:39:44 +07:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:37:28 +07:00
|
|
|
// Map Recommended Courses
|
2026-02-19 10:39:44 +07:00
|
|
|
if (courseRes.success && courseRes.data) {
|
2026-02-20 14:58:18 +07:00
|
|
|
recommendedCourses.value = courseRes.data.map((c: any) => ({
|
|
|
|
|
id: c.id,
|
|
|
|
|
title: c.title,
|
|
|
|
|
category: catMap.get(c.category_id),
|
|
|
|
|
description: c.description,
|
|
|
|
|
lessons: c.total_lessons || 0,
|
|
|
|
|
image: c.thumbnail_url || "",
|
|
|
|
|
rating: c.rating,
|
|
|
|
|
price: c.price,
|
|
|
|
|
is_free: c.is_free,
|
|
|
|
|
}));
|
2026-02-19 10:39:44 +07:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-02-20 14:58:18 +07:00
|
|
|
console.error("Failed to load dashboard data", err);
|
2026-02-19 10:39:44 +07:00
|
|
|
} finally {
|
2026-02-20 14:58:18 +07:00
|
|
|
isLoading.value = false;
|
2026-01-13 10:46:40 +07:00
|
|
|
}
|
2026-02-20 14:58:18 +07:00
|
|
|
});
|
2026-02-19 10:39:44 +07:00
|
|
|
|
|
|
|
|
// Helper for "Continue Learning" Hero Card
|
2026-02-20 14:58:18 +07:00
|
|
|
const heroCourse = computed(() => enrolledCourses.value[0] || null);
|
|
|
|
|
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
|
2026-01-13 10:46:40 +07:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-02-20 14:58:18 +07:00
|
|
|
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen font-inter pb-20 transition-colors duration-300">
|
2026-02-19 17:37:28 +07:00
|
|
|
<div class="container mx-auto px-6 md:px-12 space-y-16 mt-10">
|
2026-02-20 14:58:18 +07:00
|
|
|
<!-- 1. Dashboard Hero Banner (Refined) -->
|
|
|
|
|
<section
|
|
|
|
|
class="relative overflow-hidden bg-gradient-to-br from-white to-slate-50 dark:from-slate-900 dark:to-slate-950 rounded-[2rem] py-10 md:py-14 px-8 md:px-12 shadow-sm border border-slate-100 dark:border-slate-800 flex flex-col items-center text-center transition-colors duration-300"
|
|
|
|
|
>
|
|
|
|
|
<!-- Subtle Decorative Elements -->
|
|
|
|
|
<div
|
|
|
|
|
class="absolute top-[-20%] left-[-10%] w-[300px] h-[300px] bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-3xl -z-10"
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute bottom-[-20%] right-[-10%] w-[300px] h-[300px] bg-indigo-500/5 dark:bg-indigo-500/10 rounded-full blur-3xl -z-10"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div class="max-w-2xl space-y-6 relative z-10">
|
|
|
|
|
<h1
|
|
|
|
|
class="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 dark:text-white leading-[1.5] tracking-tight"
|
|
|
|
|
>
|
|
|
|
|
{{ $t("dashboard.heroTitle") }}
|
|
|
|
|
<span class="inline-block text-blue-600 dark:text-blue-400 mt-1 md:mt-2">{{
|
|
|
|
|
$t("dashboard.heroSubtitle")
|
|
|
|
|
}}</span>
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
<p
|
|
|
|
|
class="text-slate-500 dark:text-slate-400 font-medium text-base md:text-lg max-w-xl mx-auto leading-relaxed"
|
|
|
|
|
>
|
|
|
|
|
{{ $t("dashboard.heroDesc") }}
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-wrap justify-center gap-4 pt-4">
|
|
|
|
|
<q-btn
|
|
|
|
|
unelevated
|
|
|
|
|
rounded
|
|
|
|
|
color="primary"
|
|
|
|
|
:label="$t('dashboard.goToMyCourses')"
|
|
|
|
|
class="px-8 h-[48px] font-bold no-caps shadow-lg shadow-blue-500/10 hover:-translate-y-0.5 transition-all text-sm"
|
|
|
|
|
to="/dashboard/my-courses"
|
|
|
|
|
/>
|
|
|
|
|
<q-btn
|
|
|
|
|
outline
|
|
|
|
|
rounded
|
|
|
|
|
color="primary"
|
|
|
|
|
:label="$t('dashboard.searchNewCourses')"
|
|
|
|
|
class="px-8 h-[48px] font-bold no-caps hover:bg-white dark:hover:bg-slate-800 transition-all border-1 text-sm dark:text-white dark:border-slate-600"
|
|
|
|
|
style="border-width: 1.5px"
|
|
|
|
|
to="/browse/discovery"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- 2. Continue Learning Section -->
|
|
|
|
|
<section v-if="enrolledCourses.length > 0">
|
|
|
|
|
<div class="flex justify-between items-end mb-6">
|
|
|
|
|
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white transition-colors">
|
|
|
|
|
{{ $t("dashboard.continueLearningTitle") }}
|
|
|
|
|
</h2>
|
|
|
|
|
<NuxtLink
|
|
|
|
|
to="/dashboard/my-courses"
|
|
|
|
|
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium text-sm flex items-center gap-1 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{{ $t("dashboard.myCourses") }}
|
|
|
|
|
<q-icon name="arrow_forward" size="16px" />
|
|
|
|
|
</NuxtLink>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-23 17:44:02 +07:00
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
2026-02-20 14:58:18 +07:00
|
|
|
<!-- Hero Card (Left) -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="heroCourse"
|
2026-02-23 17:44:02 +07:00
|
|
|
class="relative group cursor-pointer rounded-2xl overflow-hidden bg-white dark:bg-[#1e293b] shadow-sm border border-gray-100 dark:border-slate-700 hover:shadow-md transition-all h-[260px] md:h-[320px]"
|
2026-02-20 14:58:18 +07:00
|
|
|
@click="
|
|
|
|
|
navigateTo(`/classroom/learning?course_id=${heroCourse.id}`)
|
|
|
|
|
"
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
:src="heroCourse.thumbnail_url"
|
|
|
|
|
class="w-full h-full object-cover brightness-75 group-hover:brightness-90 transition-all duration-500"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent p-8 flex flex-col justify-end"
|
|
|
|
|
>
|
|
|
|
|
<h3
|
|
|
|
|
class="text-white text-2xl font-bold mb-4 line-clamp-2 leading-snug shadow-black/50 drop-shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
{{ getLocalizedText(heroCourse.title) }}
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
|
|
|
<!-- Progress -->
|
|
|
|
|
<div class="w-full">
|
|
|
|
|
<div class="flex justify-end text-gray-300 text-xs mb-2">
|
|
|
|
|
<span>{{ heroCourse.progress }}%</span>
|
2026-02-19 17:37:28 +07:00
|
|
|
</div>
|
2026-02-20 14:58:18 +07:00
|
|
|
<div
|
|
|
|
|
class="h-1.5 w-full bg-white/20 rounded-full overflow-hidden"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="h-full rounded-full transition-all duration-500"
|
|
|
|
|
:class="
|
|
|
|
|
heroCourse.progress === 100
|
|
|
|
|
? 'bg-emerald-500'
|
|
|
|
|
: 'bg-blue-500'
|
|
|
|
|
"
|
|
|
|
|
:style="{ width: `${heroCourse.progress}%` }"
|
|
|
|
|
></div>
|
2026-02-19 10:39:44 +07:00
|
|
|
</div>
|
2026-02-20 14:58:18 +07:00
|
|
|
<div class="mt-4 flex justify-end">
|
|
|
|
|
<span
|
|
|
|
|
class="font-bold text-sm hover:underline transition-colors"
|
|
|
|
|
:class="
|
|
|
|
|
heroCourse.progress === 100
|
|
|
|
|
? 'text-emerald-400'
|
|
|
|
|
: 'text-white'
|
|
|
|
|
"
|
|
|
|
|
>
|
|
|
|
|
{{
|
|
|
|
|
heroCourse.progress === 100
|
|
|
|
|
? $t("dashboard.studyAgain")
|
|
|
|
|
: $t("dashboard.continue")
|
|
|
|
|
}}
|
|
|
|
|
</span>
|
2026-02-19 10:39:44 +07:00
|
|
|
</div>
|
2026-02-20 14:58:18 +07:00
|
|
|
</div>
|
2026-02-19 10:39:44 +07:00
|
|
|
</div>
|
2026-02-20 14:58:18 +07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Side List (Right) -->
|
2026-02-23 17:44:02 +07:00
|
|
|
<div class="flex flex-col gap-4">
|
2026-02-20 14:58:18 +07:00
|
|
|
<div
|
|
|
|
|
v-for="course in sideCourses"
|
|
|
|
|
:key="course.id"
|
2026-02-24 11:12:26 +07:00
|
|
|
class="flex-1 bg-white dark:!bg-slate-900/40 rounded-2xl p-4 border border-slate-100 dark:border-white/5 shadow-sm hover:shadow-md transition-all flex gap-4 items-center"
|
2026-02-20 14:58:18 +07:00
|
|
|
>
|
|
|
|
|
<div class="w-32 h-20 rounded-xl overflow-hidden flex-shrink-0">
|
|
|
|
|
<img
|
|
|
|
|
:src="course.thumbnail_url"
|
|
|
|
|
class="w-full h-full object-cover"
|
2026-02-19 10:39:44 +07:00
|
|
|
/>
|
2026-02-20 14:58:18 +07:00
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
class="flex-grow min-w-0 flex flex-col justify-between h-full py-1"
|
|
|
|
|
>
|
|
|
|
|
<h4 class="text-gray-800 dark:text-slate-200 font-bold text-sm line-clamp-2 mb-2 transition-colors">
|
|
|
|
|
{{ getLocalizedText(course.title) }}
|
|
|
|
|
</h4>
|
|
|
|
|
|
|
|
|
|
<div class="mt-auto">
|
|
|
|
|
<div
|
|
|
|
|
class="h-1.5 w-full bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden mb-2"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="h-full rounded-full transition-all duration-500"
|
|
|
|
|
:class="
|
|
|
|
|
course.progress === 100
|
|
|
|
|
? 'bg-emerald-500'
|
|
|
|
|
: 'bg-blue-600'
|
|
|
|
|
"
|
|
|
|
|
:style="{ width: `${course.progress}%` }"
|
|
|
|
|
></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex justify-end items-center text-xs">
|
|
|
|
|
<span
|
|
|
|
|
class="font-bold cursor-pointer hover:underline transition-colors"
|
|
|
|
|
:class="
|
|
|
|
|
course.progress === 100
|
|
|
|
|
? 'text-emerald-600 dark:text-emerald-400'
|
|
|
|
|
: 'text-blue-600 dark:text-blue-400'
|
|
|
|
|
"
|
|
|
|
|
@click="
|
|
|
|
|
navigateTo(`/classroom/learning?course_id=${course.id}`)
|
|
|
|
|
"
|
|
|
|
|
>
|
|
|
|
|
{{
|
|
|
|
|
course.progress === 100
|
|
|
|
|
? $t("dashboard.studyAgain")
|
|
|
|
|
: $t("dashboard.continue")
|
|
|
|
|
}}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-02-19 10:39:44 +07:00
|
|
|
</div>
|
2026-02-20 14:58:18 +07:00
|
|
|
</div>
|
2026-02-19 10:39:44 +07:00
|
|
|
</div>
|
2026-02-20 14:58:18 +07:00
|
|
|
<!-- Empty State Placeholder if less than 2 side courses -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="sideCourses.length < 2"
|
2026-02-24 11:12:26 +07:00
|
|
|
class="flex-1 bg-slate-50 dark:!bg-slate-900/30 rounded-2xl border border-dashed border-slate-200 dark:border-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-600 text-sm transition-colors"
|
2026-02-20 14:58:18 +07:00
|
|
|
>
|
|
|
|
|
{{ $t("dashboard.startNewCourse") }}
|
2026-02-19 10:39:44 +07:00
|
|
|
</div>
|
2026-02-20 14:58:18 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- 3. Knowledge Library -->
|
|
|
|
|
<section>
|
|
|
|
|
<div class="mb-6">
|
|
|
|
|
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white mb-1 transition-colors">
|
|
|
|
|
{{ $t("dashboard.knowledgeLibrary") }}
|
|
|
|
|
</h2>
|
|
|
|
|
<p class="text-gray-500 dark:text-slate-400 text-sm transition-colors">
|
|
|
|
|
{{ $t("dashboard.libraryDesc") }}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Content when courses exist -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="libraryCourses.length > 0"
|
2026-02-23 17:44:02 +07:00
|
|
|
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6"
|
2026-02-20 14:58:18 +07:00
|
|
|
>
|
|
|
|
|
<!-- Course Cards -->
|
|
|
|
|
<CourseCard
|
|
|
|
|
v-for="course in libraryCourses"
|
|
|
|
|
:key="course.id"
|
|
|
|
|
v-bind="course"
|
|
|
|
|
:image="course.thumbnail_url"
|
|
|
|
|
hide-progress
|
|
|
|
|
hide-actions
|
|
|
|
|
class="h-full md:col-span-1"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div
|
2026-02-24 11:12:26 +07:00
|
|
|
class="bg-white dark:!bg-slate-900/40 rounded-3xl border border-slate-100 dark:border-white/5 shadow-sm p-8 flex flex-col items-center justify-center text-center h-full min-h-[300px] hover:shadow-md transition-all group"
|
2026-02-20 14:58:18 +07:00
|
|
|
>
|
|
|
|
|
<p class="text-gray-600 dark:text-slate-300 font-medium mb-6 mt-4 transition-colors">
|
|
|
|
|
{{ $t("dashboard.chooseLibrary") }}
|
|
|
|
|
</p>
|
|
|
|
|
<q-btn
|
|
|
|
|
flat
|
|
|
|
|
rounded
|
|
|
|
|
no-caps
|
|
|
|
|
class="text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 px-6 py-2 font-bold group-hover:scale-105 transition-transform"
|
|
|
|
|
to="/dashboard/my-courses"
|
|
|
|
|
>
|
|
|
|
|
{{ $t("dashboard.viewAll") }}
|
|
|
|
|
<q-icon name="arrow_forward" size="18px" class="ml-2" />
|
|
|
|
|
</q-btn>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
v-else
|
2026-02-24 11:12:26 +07:00
|
|
|
class="bg-white dark:!bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800 p-12 flex flex-col items-center justify-center text-center min-h-[300px] transition-colors"
|
2026-02-20 14:58:18 +07:00
|
|
|
>
|
|
|
|
|
<div class="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-full mb-6 transition-colors">
|
|
|
|
|
<q-icon name="school" size="48px" class="text-blue-200 dark:text-blue-400" />
|
|
|
|
|
</div>
|
|
|
|
|
<h3 class="text-xl font-bold text-gray-800 dark:text-white mb-2 transition-colors">
|
|
|
|
|
{{ $t("dashboard.emptyLibraryTitle") }}
|
|
|
|
|
</h3>
|
|
|
|
|
<p class="text-gray-500 dark:text-slate-400 mb-8 max-w-md transition-colors">
|
|
|
|
|
{{ $t("dashboard.emptyLibraryDesc") }}
|
|
|
|
|
</p>
|
|
|
|
|
<q-btn
|
|
|
|
|
unelevated
|
|
|
|
|
rounded
|
|
|
|
|
no-caps
|
|
|
|
|
class="bg-blue-600 text-white px-8 py-3 font-bold hover:bg-blue-700 shadow-lg shadow-blue-500/20 transition-all hover:scale-105"
|
|
|
|
|
to="/browse/discovery"
|
|
|
|
|
>
|
|
|
|
|
{{ $t("dashboard.viewAllCourses") }}
|
|
|
|
|
</q-btn>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- 5. Recommended Courses -->
|
|
|
|
|
<section class="pb-20">
|
|
|
|
|
<div class="mb-6">
|
|
|
|
|
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white text-left transition-colors">
|
|
|
|
|
{{ $t("dashboard.recommendedCourses") }}
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Recommended Grid (3 columns) -->
|
|
|
|
|
<div
|
2026-02-23 17:44:02 +07:00
|
|
|
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in"
|
2026-02-20 14:58:18 +07:00
|
|
|
>
|
|
|
|
|
<CourseCard
|
|
|
|
|
v-for="course in recommendedCourses"
|
|
|
|
|
:key="course.id"
|
|
|
|
|
v-bind="course"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Loading State -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="recommendedCourses.length === 0 && !isLoading"
|
|
|
|
|
class="flex justify-center py-10 opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<div class="text-gray-400 dark:text-slate-500">{{ $t("dashboard.noRecommended") }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-02-19 10:39:44 +07:00
|
|
|
</div>
|
2026-01-13 10:46:40 +07:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-02-19 10:39:44 +07:00
|
|
|
/* Scoped specific styles */
|
|
|
|
|
.animate-fade-in {
|
|
|
|
|
animation: fadeIn 0.5s ease-out;
|
|
|
|
|
}
|
|
|
|
|
@keyframes fadeIn {
|
2026-02-20 14:58:18 +07:00
|
|
|
from {
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transform: translateY(10px);
|
|
|
|
|
}
|
|
|
|
|
to {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
transform: translateY(0);
|
|
|
|
|
}
|
2026-01-13 10:46:40 +07:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 10:39:44 +07:00
|
|
|
:deep(.q-btn) {
|
2026-02-20 14:58:18 +07:00
|
|
|
text-transform: none; /* Prevent uppercase in Q-Btns */
|
2026-01-13 10:46:40 +07:00
|
|
|
}
|
|
|
|
|
</style>
|