feat: add new pages for course discovery, course details, classroom activities, user dashboard, password reset, and internationalization support.

This commit is contained in:
supalerk-ar66 2026-02-02 14:37:26 +07:00
parent 7ac1a5af0a
commit 4c9b6b0f3f
10 changed files with 570 additions and 175 deletions

View file

@ -506,9 +506,11 @@ export const useCourse = () => {
// ฟังก์ชันส่งคำตอบ Quiz
// Endpoint: POST /students/courses/:cid/lessons/:lid/quiz/submit
const submitQuiz = async (courseId: number, lessonId: number, answers: QuizAnswerSubmission[]) => {
const submitQuiz = async (courseId: number, lessonId: number, answers: QuizAnswerSubmission[], alreadyPassed: boolean = false) => {
try {
const body: QuizSubmitRequest = { answers }
// NOTE: Backend crashes with 500 if we send extra fields like 'already_passed'.
// Reverting to strict body structure.
const body = { answers }
const data = await $fetch<{ code: number; message: string; data: QuizResult }>(`${API_BASE_URL}/students/courses/${courseId}/lessons/${lessonId}/quiz/submit`, {
method: 'POST',
@ -587,6 +589,32 @@ export const useCourse = () => {
}
}
// ฟังก์ชันดึงประกาศของคอร์ส (Announcements)
// Endpoint: GET /student/courses/:id/announcements (Note: 'student' singular based on recent API update)
const fetchCourseAnnouncements = async (courseId: number) => {
try {
const data = await $fetch<{ code: number; message: string; data: any[] }>(`${API_BASE_URL}/student/courses/${courseId}/announcements`, {
method: 'GET',
headers: token.value ? {
Authorization: `Bearer ${token.value}`
} : {}
})
return {
success: true,
data: data.data
}
} catch (err: any) {
console.error('Fetch course announcements failed:', err)
return {
success: false,
error: err.data?.message || err.message || 'Error fetching announcements',
code: err.data?.code,
status: err.status
}
}
}
return {
fetchCourses,
fetchCourseById,
@ -600,7 +628,8 @@ export const useCourse = () => {
markLessonComplete,
submitQuiz,
generateCertificate,
getCertificate
getCertificate,
fetchCourseAnnouncements
}
}

View file

@ -162,7 +162,10 @@
"loadingTitle": "Loading...",
"chapter": "Chapter",
"lessons": "Lessons",
"attachments": "Attachments"
"attachments": "Attachments",
"announcements": "Course Announcements",
"posts": "Posts",
"noAnnouncements": "No announcements yet"
},
"quiz": {
"exitTitle": "Exit Quiz",

View file

@ -144,6 +144,7 @@
"newBadge": "ใหม่",
"popularBadge": "ยอดนิยม",
"save": "บันทึก",
"close": "ปิด",
"cancel": "ยกเลิก",
"required": "กรุณากรอกข้อมูล",
"invalidEmail": "อีเมลไม่ถูกต้อง",
@ -162,7 +163,10 @@
"loadingTitle": "กำลังโหลด...",
"chapter": "บทที่",
"lessons": "บทเรียน",
"attachments": "เอกสารประกอบ"
"attachments": "เอกสารประกอบ",
"announcements": "ประกาศในคอร์ส",
"posts": "โพสต์",
"noAnnouncements": "ยังไม่มีประกาศในขณะนี้"
},
"quiz": {
"startTitle": "แบบทดสอบ",

View file

@ -133,20 +133,7 @@ onMounted(() => {
</template>
</q-input>
<!-- Sort Dropdown -->
<q-select
v-model="sortOption"
:options="sortOptions"
dense
outlined
rounded
:dark="false"
class="disc-sort w-40"
:options-dark="false"
options-dense
popup-content-class="text-slate-900"
behavior="menu"
/>
</div>
</div>
@ -212,7 +199,7 @@ onMounted(() => {
<!-- 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">
<div v-if="filteredCourses.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<CourseCard
v-for="course in filteredCourses"
:key="course.id"

View file

@ -20,7 +20,7 @@ const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { user } = useAuth()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress } = useCourse()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements } = useCourse()
// Media Prefs (Global Volume)
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs()
@ -33,6 +33,54 @@ const courseId = computed(() => Number(route.query.course_id))
// ==========================================
// courseData: ()
const courseData = ref<any>(null)
const announcements = ref<any[]>([]) // Announcements state
const showAnnouncementsModal = ref(false) // Modal state
const hasUnreadAnnouncements = ref(false) // Unread state tracking
// Helper for persistent read status
const getAnnouncementStorageKey = () => {
if (!user.value?.id || !courseId.value) return ''
return `read_announcements:${user.value.id}:${courseId.value}`
}
const checkUnreadAnnouncements = () => {
if (!announcements.value || announcements.value.length === 0) {
hasUnreadAnnouncements.value = false
return
}
if (typeof window === 'undefined') return
const key = getAnnouncementStorageKey()
if (!key) return
const lastRead = localStorage.getItem(key)
if (!lastRead) {
hasUnreadAnnouncements.value = true
return
}
const lastReadDate = new Date(lastRead).getTime()
const hasNew = announcements.value.some(a => {
const annDate = new Date(a.created_at || Date.now()).getTime()
// Check if announcement is strictly newer than last read
return annDate > lastReadDate
})
hasUnreadAnnouncements.value = hasNew
}
// Handler for opening announcements
const handleOpenAnnouncements = () => {
showAnnouncementsModal.value = true
hasUnreadAnnouncements.value = false // Clear unread badge on click
const key = getAnnouncementStorageKey()
if (key) {
localStorage.setItem(key, new Date().toISOString())
}
}
// currentLesson:
const currentLesson = ref<any>(null)
const isLoading = ref(true) //
@ -89,6 +137,13 @@ const loadCourseData = async () => {
loadLesson(availableLesson.id)
}
}
// Fetch Announcements
const annRes = await fetchCourseAnnouncements(courseId.value)
if (annRes.success) {
announcements.value = annRes.data || []
checkUnreadAnnouncements()
}
}
} catch (error) {
console.error('Error loading course:', error)
@ -244,35 +299,59 @@ const togglePlay = () => {
isPlaying.value = !isPlaying.value
}
const isCompleting = ref(false) // Flag to prevent race conditions during completion
// -----------------------------------------------------
// ROBUST PROGRESS SAVING SYSTEM (Hybrid: Local + Server)
// -----------------------------------------------------
// Main Server Save Function
const performSaveProgress = async (force: boolean = false, keepalive: boolean = false) => {
if (!videoRef.value || !currentLesson.value || currentLesson.value.type !== 'VIDEO') return
const lesson = currentLesson.value
if (!videoRef.value || !lesson || lesson.type !== 'VIDEO') return
if (!lesson.progress) return
// 1. Completed Guard: Stop everything if already completed
if (lesson.progress.is_completed) return
// 2. Race Condition Guard: Stop if currently completing
if (isCompleting.value) return
const now = Date.now()
const maxSec = Math.floor(maxWatchedTime.value) // Use max watched time
const durationSec = Math.floor(videoRef.value.duration || 0)
// 1. Validation: Don't save if progress hasn't increased (Monotonic Check)
// 3. Monotonic Check: Don't save if progress hasn't increased (unless forced)
if (!force && maxSec <= lastSavedTime.value) return
// 2. Validation: Server Throttle (15 seconds)
// 4. Throttle Check: Server Throttle (15 seconds)
if (!force && (now - lastSavedTimestamp.value < 15000)) return
// Save
// Prepare for Save
lastSavedTime.value = maxSec
lastSavedTimestamp.value = now
// Check if this save might complete the lesson (e.g. > 90% or forced end)
const isFinishing = force || (durationSec > 0 && maxSec >= durationSec * 0.9)
if (isFinishing) {
isCompleting.value = true
}
const res = await saveVideoProgress(currentLesson.value.id, maxSec, durationSec, keepalive)
// Check completion
if (res.success && res.data?.is_completed) {
markLessonAsCompletedLocally(currentLesson.value.id)
try {
const res = await saveVideoProgress(lesson.id, maxSec, durationSec, keepalive)
// Handle Completion Response
if (res.success && res.data?.is_completed) {
markLessonAsCompletedLocally(lesson.id)
if (lesson.progress) lesson.progress.is_completed = true
}
} catch (err) {
console.error('Save progress failed', err)
} finally {
if (isFinishing) {
isCompleting.value = false
}
}
}
@ -284,6 +363,8 @@ const markLessonAsCompletedLocally = (lessonId: number) => {
if (lesson) {
// Compatible with API structure
lesson.is_completed = true
if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true
break
}
}
@ -313,7 +394,7 @@ const updateProgress = () => {
lastLocalSaveTimestamp.value = now
}
// 2. Server Save Throttle (15 seconds) checked inside function
// 2. Server Save Throttle (handled inside performSaveProgress)
performSaveProgress(false, false)
}
}
@ -371,7 +452,10 @@ onBeforeUnmount(() => {
if (currentLesson.value?.id) {
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
}
performSaveProgress(true, true)
// Only save if not completed
if (currentLesson.value?.progress && !currentLesson.value.progress.is_completed) {
performSaveProgress(true, true)
}
})
const handleVisibilityChange = () => {
@ -407,15 +491,16 @@ watch(isPlaying, (playing) => {
const onVideoEnded = async () => {
isPlaying.value = false
// Safety check BEFORE trying to save
const lesson = currentLesson.value
if (!lesson || !lesson.progress || lesson.progress.is_completed || isCompleting.value) return
// Force save progress at 100%
await performSaveProgress(true, false)
// Call explicit complete endpoint if exists
// REMOVED: User requested to remove explicit complete call
// Just refresh local state assuming server handles completion via progress
if (currentLesson.value) {
// Double check completion state
if (currentLesson.value && !currentLesson.value.progress?.is_completed) {
markLessonAsCompletedLocally(currentLesson.value.id)
}
}
@ -484,6 +569,23 @@ onBeforeUnmount(() => {
>
<div v-if="courseData" class="h-full scroll">
<q-list class="pb-10">
<!-- Announcements Sidebar Item -->
<q-item
clickable
v-ripple
@click="handleOpenAnnouncements"
class="bg-blue-50 dark:bg-blue-900/10 border-b border-blue-100 dark:border-blue-900/20"
>
<q-item-section>
<q-item-label class="font-bold text-slate-800 dark:text-blue-200 text-sm pl-2">
{{ $t('classroom.announcements', 'ประกาศในคอร์ส') }}
</q-item-label>
</q-item-section>
<q-item-section side v-if="hasUnreadAnnouncements">
<q-badge color="red" rounded label="New" />
</q-item-section>
</q-item>
<template v-for="chapter in courseData.chapters" :key="chapter.id">
<q-item-label header class="bg-slate-100 dark:bg-slate-800 text-[var(--text-main)] font-bold sticky top-0 z-10 border-b dark:border-white/5 text-sm py-4">
{{ getLocalizedText(chapter.title) }}
@ -531,7 +633,7 @@ onBeforeUnmount(() => {
<!-- Video Player & Content Area -->
<div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow">
<!-- Video Player -->
<div v-if="currentLesson && videoSrc" class="bg-black rounded-xl overflow-hidden shadow-lg mb-6 aspect-video relative group">
<div v-if="currentLesson && videoSrc" class="bg-black rounded-xl overflow-hidden shadow-2xl mb-6 aspect-video relative group ring-1 ring-white/10">
<video
ref="videoRef"
:src="videoSrc"
@ -543,13 +645,13 @@ onBeforeUnmount(() => {
/>
<!-- 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">
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 via-black/40 to-transparent transition-opacity opacity-0 group-hover:opacity-100">
<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 group/progress" @click="seek">
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded group-hover/progress:h-1.5 transition-all" :style="{ width: videoProgress + '%' }"></div>
<div class="relative flex-grow h-1.5 bg-white/20 rounded-full cursor-pointer group/progress overflow-hidden" @click="seek">
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded-full group-hover/progress:bg-blue-400 transition-all shadow-[0_0_10px_rgba(59,130,246,0.5)]" :style="{ width: videoProgress + '%' }"></div>
</div>
<span class="text-xs font-mono">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
<span class="text-xs font-mono font-medium opacity-90">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
<!-- Volume Control -->
<div class="flex items-center gap-2 group/volume">
@ -571,41 +673,48 @@ onBeforeUnmount(() => {
</div>
<!-- Lesson Info -->
<div v-if="currentLesson" class="bg-[var(--bg-surface)] p-6 rounded-2xl shadow-sm border border-[var(--border-color)] mt-6">
<div v-if="currentLesson" class="bg-[var(--bg-surface)] p-6 md:p-8 rounded-3xl shadow-sm border border-[var(--border-color)]">
<!-- ใชจากตวแปรกลาง: จะแยกโหมดใหตโนม (สวาง=ดำ / =ขาว) -->
<h1 class="text-2xl md:text-3xl font-bold mb-3 text-slate-900 dark:text-white">{{ getLocalizedText(currentLesson.title) }}</h1>
<div class="flex items-start justify-between gap-4 mb-4">
<h1 class="text-2xl md:text-3xl font-bold text-slate-900 dark:text-white leading-tight font-display">{{ getLocalizedText(currentLesson.title) }}</h1>
</div>
<p class="text-slate-700 dark:text-slate-300 text-base md:text-lg" v-if="currentLesson.description">{{ getLocalizedText(currentLesson.description) }}</p>
<p class="text-slate-600 dark:text-slate-400 text-base md:text-lg leading-relaxed mb-6" v-if="currentLesson.description">{{ getLocalizedText(currentLesson.description) }}</p>
<!-- Lesson Content Area (Text/HTML) -->
<div v-if="currentLesson.type === 'QUIZ'" class="mt-6 p-8 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)] text-center">
<q-icon name="quiz" size="4rem" color="primary" class="mb-4" />
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle', 'แบบทดสอบ') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-6">{{ getLocalizedText(currentLesson.quiz?.description || currentLesson.description) }}</p>
<div v-if="currentLesson.type === 'QUIZ'" class="p-8 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-slate-800 dark:to-slate-800/50 rounded-2xl border border-blue-100 dark:border-white/5 text-center">
<div class="bg-white dark:bg-slate-700 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm text-blue-500 dark:text-blue-300">
<q-icon name="quiz" size="40px" />
</div>
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle', 'แบบทดสอบท้ายบทเรียน') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-6 max-w-md mx-auto">{{ getLocalizedText(currentLesson.quiz?.description || currentLesson.description) }}</p>
<div class="flex justify-center gap-4 text-sm text-slate-500 dark:text-slate-400 mb-8">
<span v-if="currentLesson.quiz?.questions?.length"><q-icon name="format_list_numbered" /> {{ currentLesson.quiz.questions.length }} </span>
<span v-if="currentLesson.quiz?.time_limit"><q-icon name="schedule" /> {{ currentLesson.quiz.time_limit }} นาท</span>
<div class="flex justify-center flex-wrap gap-3 text-sm text-slate-600 dark:text-slate-300 mb-8">
<span v-if="currentLesson.quiz?.questions?.length" class="px-3 py-1 bg-white dark:bg-slate-900 rounded-full border border-gray-200 dark:border-white/10 shadow-sm flex items-center gap-1.5"><q-icon name="format_list_numbered" size="14px" class="text-blue-500" /> {{ currentLesson.quiz.questions.length }} </span>
<span v-if="currentLesson.quiz?.time_limit" class="px-3 py-1 bg-white dark:bg-slate-900 rounded-full border border-gray-200 dark:border-white/10 shadow-sm flex items-center gap-1.5"><q-icon name="schedule" size="14px" class="text-orange-500" /> {{ currentLesson.quiz.time_limit }} นาท</span>
</div>
<q-btn
color="primary"
class="bg-blue-600 text-white shadow-lg shadow-blue-600/30 hover:shadow-blue-600/50 transition-all font-bold px-8"
size="lg"
rounded
no-caps
:label="$t('quiz.startBtn', 'เริ่มทำแบบทดสอบ')"
icon="play_arrow"
@click="$router.push(`/classroom/quiz?course_id=${courseId}&lesson_id=${currentLesson.id}`)"
/>
</div>
<div v-else-if="currentLesson.content" class="mt-6 prose dark:prose-invert max-w-none p-6 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)]">
<div v-html="getLocalizedText(currentLesson.content)" class="text-base md:text-lg leading-relaxed text-slate-900 dark:text-slate-200"></div>
<div v-else-if="currentLesson.content" class="prose prose-lg dark:prose-invert max-w-none p-6 md:p-8 bg-gray-50 dark:bg-slate-800/50 rounded-2xl border border-gray-100 dark:border-white/5">
<div v-html="getLocalizedText(currentLesson.content)" class="leading-relaxed text-slate-800 dark:text-slate-200"></div>
</div>
<!-- Attachments Section -->
<div v-if="currentLesson.attachments && currentLesson.attachments.length > 0" class="mt-8">
<div v-if="currentLesson.attachments && currentLesson.attachments.length > 0" class="mt-8 pt-6 border-t border-gray-100 dark:border-white/5">
<h3 class="text-lg font-bold mb-4 text-slate-900 dark:text-white flex items-center gap-2">
<q-icon name="attach_file" />
{{ $t('classroom.attachments') || 'เอกสารประกอบ' }}
<div class="w-8 h-8 rounded-lg bg-orange-100 dark:bg-orange-900/30 text-orange-600 flex items-center justify-center">
<q-icon name="attach_file" size="18px" />
</div>
{{ $t('classroom.attachments') || 'เอกสารประกอบการเรียน' }}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<a
@ -613,20 +722,21 @@ onBeforeUnmount(() => {
:key="file.file_name"
:href="file.presigned_url"
target="_blank"
class="flex items-center gap-4 p-4 rounded-xl border border-slate-200 dark:!border-white/10 bg-white dark:!bg-slate-800 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors group"
class="flex items-center gap-4 p-4 rounded-xl border border-slate-200 dark:!border-white/10 bg-white dark:!bg-slate-800 hover:border-blue-300 dark:hover:border-blue-700 hover:shadow-md transition-all group relative overflow-hidden"
>
<div class="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-600 flex items-center justify-center">
<div class="w-12 h-12 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-500 flex items-center justify-center flex-shrink-0">
<q-icon name="picture_as_pdf" size="24px" />
</div>
<div class="flex-1 min-w-0">
<div class="font-bold text-slate-900 dark:text-slate-200 truncate group-hover:text-blue-600 transition-colors">
<div class="flex-1 min-w-0 z-10">
<div class="font-bold text-slate-900 dark:text-slate-200 truncate group-hover:text-blue-600 transition-colors text-sm md:text-base">
{{ file.file_name }}
</div>
<div class="text-xs text-slate-500 dark:text-slate-400">
<div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{{ (file.file_size / 1024 / 1024).toFixed(2) }} MB
</div>
</div>
<q-icon name="download" class="text-slate-400 group-hover:text-blue-600" />
<div class="absolute inset-0 bg-blue-50/50 dark:bg-blue-900/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<q-icon name="download" class="text-slate-300 group-hover:text-blue-500 z-10" />
</a>
</div>
</div>
@ -635,6 +745,100 @@ onBeforeUnmount(() => {
</q-page>
</q-page-container>
<!-- Announcements Modal -->
<q-dialog v-model="showAnnouncementsModal" backdrop-filter="blur(4px)">
<q-card class="min-w-[320px] md:min-w-[600px] rounded-3xl overflow-hidden bg-white dark:bg-slate-900 shadow-2xl">
<q-card-section class="bg-white dark:bg-slate-900 border-b border-gray-100 dark:border-white/5 p-5 flex items-center justify-between sticky top-0 z-10">
<div>
<div class="text-xl font-bold flex items-center gap-2 text-slate-900 dark:text-white">
<div class="w-10 h-10 rounded-full bg-blue-50 dark:bg-blue-900/30 text-primary flex items-center justify-center">
<q-icon name="campaign" size="22px" />
</div>
{{ $t('classroom.announcements', 'ประกาศในคอร์สเรียน') }}
</div>
<div class="text-xs text-slate-500 dark:text-slate-400 ml-12 mt-1">{{ announcements.length }} รายการ</div>
</div>
<q-btn icon="close" flat round dense v-close-popup class="text-slate-400 hover:text-slate-700 dark:hover:text-white bg-slate-50 dark:bg-slate-800" />
</q-card-section>
<q-card-section class="p-0 scroll max-h-[70vh] bg-slate-50 dark:bg-[#0B0F1A]">
<div v-if="announcements.length > 0" class="p-4 space-y-4">
<div
v-for="(ann, index) in announcements"
:key="index"
class="p-5 rounded-2xl bg-white dark:bg-slate-800 shadow-sm border border-gray-200 dark:border-white/5 transition-all hover:shadow-md relative overflow-hidden group"
:class="{'ring-2 ring-orange-200 dark:ring-orange-900/40 bg-orange-50/50 dark:bg-orange-900/10': ann.is_pinned}"
>
<!-- Pinned Banner -->
<div v-if="ann.is_pinned" class="absolute top-0 right-0 p-3">
<q-icon name="push_pin" color="orange" size="18px" class="transform rotate-45" />
</div>
<div class="flex items-start gap-4 mb-3">
<q-avatar
:color="ann.is_pinned ? 'orange-100' : 'blue-50'"
:text-color="ann.is_pinned ? 'orange-700' : 'blue-700'"
size="42px"
font-size="20px"
class="shadow-sm"
>
<span class="font-bold">{{ (getLocalizedText(ann.title) || 'A').charAt(0) }}</span>
</q-avatar>
<div class="flex-1">
<div class="font-bold text-lg text-slate-900 dark:text-white leading-tight pr-8 capitalize font-display">
{{ getLocalizedText(ann.title) || 'ประกาศ' }}
</div>
<div class="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-2 mt-1.5">
<span class="flex items-center gap-1 bg-slate-100 dark:bg-slate-700 px-2 py-0.5 rounded-md">
<q-icon name="today" size="12px" />
{{ new Date(ann.created_at || Date.now()).toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' }) }}
</span>
<span class="flex items-center gap-1 bg-slate-100 dark:bg-slate-700 px-2 py-0.5 rounded-md">
<q-icon name="access_time" size="12px" />
{{ new Date(ann.created_at || Date.now()).toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit' }) }}
</span>
</div>
</div>
</div>
<div class="text-slate-600 dark:text-slate-300 text-sm leading-relaxed whitespace-pre-line pl-[58px] mb-4">
{{ getLocalizedText(ann.content) }}
</div>
<!-- Attachments in Announcement -->
<div v-if="ann.attachments && ann.attachments.length > 0" class="pl-[58px] mt-4 pt-4 border-t border-gray-100 dark:border-white/5">
<div class="text-xs font-bold text-slate-500 dark:text-slate-400 mb-3 flex items-center gap-1.5 uppercase tracking-wider">
<q-icon name="attach_file" /> {{ $t('classroom.attachments') || 'Attachments' }}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a
v-for="file in ann.attachments"
:key="file.id"
:href="file.presigned_url"
target="_blank"
class="flex items-center gap-3 p-3 rounded-xl bg-gray-50 dark:bg-slate-700/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 text-sm text-slate-700 dark:text-slate-200 border border-gray-200 dark:border-white/10 hover:border-blue-200 dark:hover:border-blue-700/50 transition-all group/file"
>
<div class="w-8 h-8 rounded-lg bg-white dark:bg-slate-600 flex items-center justify-center shadow-sm text-red-500">
<q-icon name="description" size="18px" />
</div>
<span class="truncate flex-1 font-medium">{{ file.file_name }}</span>
<q-icon name="download" size="14px" class="text-slate-400 group-hover/file:text-blue-500" />
</a>
</div>
</div>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center p-12 text-slate-400 min-h-[300px]">
<div class="w-24 h-24 bg-slate-100 dark:bg-slate-800 rounded-full mb-6 flex items-center justify-center">
<q-icon name="notifications_off" size="3rem" class="text-slate-300 dark:text-slate-600" />
</div>
<div class="text-base font-medium text-slate-600 dark:text-slate-400">{{ $t('classroom.noAnnouncements', 'ไม่มีประกาศในขณะนี้') }}</div>
<div class="text-sm text-slate-400 mt-2">โพสตใหม จากผสอนจะปรากฏท</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</q-layout>
</template>

View file

@ -58,6 +58,8 @@ const getLocalizedText = (text: any) => {
return text.th || text.en || ''
}
const lessonProgress = ref<any>(null)
// Data Fetching
const loadData = async () => {
isLoading.value = true
@ -72,6 +74,7 @@ const loadData = async () => {
if (lessonRes.success) {
// Determine if data is directly the quiz or nested
quizData.value = lessonRes.data.quiz || lessonRes.data
lessonProgress.value = lessonRes.progress // Capture progress
if (quizData.value?.time_limit) {
timeLeft.value = quizData.value.time_limit * 60
}
@ -166,7 +169,7 @@ const retryQuiz = () => {
}
const submitQuiz = async (auto = false) => {
// if (!auto && !confirm(t('quiz.submitConfirm'))) return <-- Removed logic
// if (!auto && !confirm(t('quiz.submitConfirm'))) return
if (timerInterval) clearInterval(timerInterval)
@ -180,11 +183,18 @@ const submitQuiz = async (auto = false) => {
choice_id: cId
}))
// Check if already passed
const alreadyPassed = lessonProgress.value?.is_passed || lessonProgress.value?.is_completed || false
// Call API
const res = await apiSubmitQuiz(courseId, lessonId, answersPayload)
const res = await apiSubmitQuiz(courseId, lessonId, answersPayload, alreadyPassed)
if (res.success) {
quizResult.value = res.data
// Update local progress if passed and not previously passed
if (res.data.is_passed && !alreadyPassed) {
if (lessonProgress.value) lessonProgress.value.is_passed = true
}
} else {
// Fallback error handling
alert(res.error || 'Failed to submit quiz')
@ -214,9 +224,23 @@ onMounted(() => {
loadData()
})
onUnmounted(() => {
if (timerInterval) clearInterval(timerInterval)
})
const reviewQuiz = () => {
currentScreen.value = 'review'
}
// Helper to get choice label (A, B, C...)
const getChoiceLabel = (index: number) => {
return String.fromCharCode(65 + index) // 65 is 'A'
}
const getCorrectChoiceId = (questionId: number) => {
if (!quizResult.value?.answers_review) return null
// Type checking for safety
const review = Array.isArray(quizResult.value.answers_review)
? quizResult.value.answers_review.find((r: any) => r.question_id === questionId)
: null
return review ? review.correct_choice_id : null
}
</script>
<template>
@ -234,7 +258,7 @@ onUnmounted(() => {
</button>
<div class="w-[1px] h-4 bg-slate-300 dark:bg-white/10 mx-4"/>
<h1 class="text-base font-bold text-slate-900 dark:text-white truncate max-w-[200px] md:max-w-md hidden md:block">
{{ quizData ? getLocalizedText(quizData.title) : (courseData ? getLocalizedText(courseData.course.title) : $t('quiz.startTitle')) }}
{{ currentScreen === 'review' ? $t('quiz.reviewAnswers', 'เฉลยคำตอบ') : (quizData ? getLocalizedText(quizData.title) : (courseData ? getLocalizedText(courseData.course.title) : $t('quiz.startTitle'))) }}
</h1>
</div>
@ -258,49 +282,50 @@ onUnmounted(() => {
<template v-else>
<!-- 1. START SCREEN -->
<div v-if="currentScreen === 'start'" class="w-full max-w-[640px] animate-fade-in py-12">
<div class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-14 shadow-lg dark:shadow-2xl relative overflow-hidden transition-colors">
<div class="flex justify-center mb-10">
<div class="w-20 h-20 rounded-3xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 flex items-center justify-center shadow-inner">
<q-icon name="quiz" size="2rem" color="primary" />
</div>
</div>
<!-- ... (Start Screen is unchanged but needs to be here for context) ... -->
<div class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-14 shadow-lg dark:shadow-2xl relative overflow-hidden transition-colors">
<div class="flex justify-center mb-10">
<div class="w-20 h-20 rounded-3xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 flex items-center justify-center shadow-inner">
<q-icon name="quiz" size="2rem" color="primary" />
</div>
</div>
<div class="text-center mb-10">
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-2 tracking-tight">
{{ quizData ? getLocalizedText(quizData.title) : $t('quiz.startTitle') }}
</h2>
<div class="flex justify-center gap-4 text-sm text-slate-500 dark:text-slate-400 mt-2">
<span v-if="quizData?.questions?.length"><q-icon name="format_list_numbered" /> {{ quizData.questions.length }} {{ $t('quiz.questions') }}</span>
<span v-if="quizData?.time_limit"><q-icon name="schedule" /> {{ quizData.time_limit }} {{ $t('quiz.minutes') }}</span>
</div>
<p class="text-[13px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest leading-none mt-6">
{{ $t('quiz.preparationTitle') }}
</p>
</div>
<div class="text-center mb-10">
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-2 tracking-tight">
{{ quizData ? getLocalizedText(quizData.title) : $t('quiz.startTitle') }}
</h2>
<div class="flex justify-center gap-4 text-sm text-slate-500 dark:text-slate-400 mt-2">
<span v-if="quizData?.questions?.length"><q-icon name="format_list_numbered" /> {{ quizData.questions.length }} {{ $t('quiz.questions') }}</span>
<span v-if="quizData?.time_limit"><q-icon name="schedule" /> {{ quizData.time_limit }} {{ $t('quiz.minutes') }}</span>
</div>
<p class="text-[13px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest leading-none mt-6">
{{ $t('quiz.preparationTitle') }}
</p>
</div>
<!-- Instruction Box -->
<div class="bg-slate-50 dark:bg-[#0b121f]/80 p-8 rounded-3xl mb-8 border border-slate-100 dark:border-white/5">
<h3 class="text-[12px] font-black text-slate-500 dark:text-slate-400 mb-6 uppercase tracking-[0.2em] flex items-center gap-2">
{{ $t('quiz.instructionTitle') }}
</h3>
<ul class="space-y-4">
<li class="flex items-start gap-3">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500 mt-1.5 flex-shrink-0"/>
<span class="text-sm text-slate-600 dark:text-slate-300 font-medium leading-relaxed">
{{ quizData?.description ? getLocalizedText(quizData.description) : $t('quiz.instruction1') }}
</span>
</li>
</ul>
</div>
<!-- Instruction Box -->
<div class="bg-slate-50 dark:bg-[#0b121f]/80 p-8 rounded-3xl mb-8 border border-slate-100 dark:border-white/5">
<h3 class="text-[12px] font-black text-slate-500 dark:text-slate-400 mb-6 uppercase tracking-[0.2em] flex items-center gap-2">
{{ $t('quiz.instructionTitle') }}
</h3>
<ul class="space-y-4">
<li class="flex items-start gap-3">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500 mt-1.5 flex-shrink-0"/>
<span class="text-sm text-slate-600 dark:text-slate-300 font-medium leading-relaxed">
{{ quizData?.description ? getLocalizedText(quizData.description) : $t('quiz.instruction1') }}
</span>
</li>
</ul>
</div>
<button
class="w-full py-5 bg-blue-600 hover:bg-blue-500 text-white rounded-[20px] font-black text-sm tracking-wider transition-all shadow-xl shadow-blue-600/20 active:scale-[0.98]"
@click="startQuiz"
>
{{ $t('quiz.startBtn') }}
</button>
</div>
<button
class="w-full py-5 bg-blue-600 hover:bg-blue-500 text-white rounded-[20px] font-black text-sm tracking-wider transition-all shadow-xl shadow-blue-600/20 active:scale-[0.98]"
@click="startQuiz"
>
{{ $t('quiz.startBtn') }}
</button>
</div>
</div>
<!-- 2. TAKING SCREEN -->
@ -419,6 +444,79 @@ onUnmounted(() => {
>
{{ $t('quiz.retryBtn') }}
</button>
<button
@click="reviewQuiz"
class="w-full py-2 text-blue-500 hover:text-blue-700 dark:hover:text-blue-400 font-bold text-sm transition-colors mt-2"
>
{{ $t('quiz.reviewAnswers', 'ดูเฉลยคำตอบ') }}
</button>
</div>
</div>
</div>
<!-- 4. REVIEW SCREEN -->
<div v-if="currentScreen === 'review'" class="w-full max-w-[840px] animate-fade-in py-12 pb-24">
<div class="space-y-6">
<div
v-for="(question, qIndex) in quizData?.questions"
:key="question.id"
class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[24px] p-8 shadow-sm"
>
<div class="flex items-start gap-4">
<span class="flex-shrink-0 w-8 h-8 rounded-lg bg-slate-100 dark:bg-white/10 flex items-center justify-center font-black text-slate-500 dark:text-slate-300 text-sm">
{{ qIndex + 1 }}
</span>
<div class="flex-1">
<h3 class="font-bold text-lg text-slate-900 dark:text-white mb-6">{{ getLocalizedText(question.question) }}</h3>
<div class="grid gap-3">
<div
v-for="(choice, cIndex) in question.choices"
:key="choice.id"
class="flex items-center gap-3 p-4 rounded-xl border-2 transition-all relative overflow-hidden"
:class="{
'border-emerald-500 bg-emerald-50 dark:bg-emerald-500/10': choice.id === getCorrectChoiceId(question.id),
'border-red-500 bg-red-50 dark:bg-red-500/10': userAnswers[question.id] === choice.id && choice.id !== getCorrectChoiceId(question.id),
'border-slate-100 dark:border-white/5 opacity-60': userAnswers[question.id] !== choice.id && choice.id !== getCorrectChoiceId(question.id)
}"
>
<!-- Indicator Icon -->
<div
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 font-bold text-sm border-2"
:class="{
'bg-emerald-500 border-emerald-500 text-white': choice.id === getCorrectChoiceId(question.id),
'bg-red-500 border-red-500 text-white': userAnswers[question.id] === choice.id && choice.id !== getCorrectChoiceId(question.id),
'border-slate-300 text-slate-400': choice.id !== getCorrectChoiceId(question.id) && userAnswers[question.id] !== choice.id
}"
>
<q-icon v-if="choice.id === getCorrectChoiceId(question.id)" name="check" size="16px" />
<q-icon v-else-if="userAnswers[question.id] === choice.id" name="close" size="16px" />
<span v-else>{{ getChoiceLabel(cIndex) }}</span>
</div>
<span class="font-medium text-slate-700 dark:text-slate-300">{{ getLocalizedText(choice.text) }}</span>
<!-- Label Badge -->
<div v-if="choice.id === getCorrectChoiceId(question.id)" class="ml-auto px-2 py-0.5 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs font-bold rounded uppercase tracking-wider">
{{ $t('quiz.correctLabel', 'Correct') }}
</div>
<div v-if="userAnswers[question.id] === choice.id && choice.id !== getCorrectChoiceId(question.id)" class="ml-auto px-2 py-0.5 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300 text-xs font-bold rounded uppercase tracking-wider">
{{ $t('quiz.yourAnswer', 'Your Answer') }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="fixed bottom-0 left-0 right-0 p-4 bg-white/80 dark:bg-[#0b0f1a]/90 backdrop-blur-md border-t border-slate-200 dark:border-white/5 flex justify-center z-50">
<button
@click="confirmExit"
class="px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold shadow-lg shadow-blue-500/30"
>
{{ $t('quiz.backToLesson') }}
</button>
</div>
</div>
</div>

View file

@ -36,7 +36,15 @@ const handleEnroll = async () => {
if (res.success) {
// "" params enrolled=true
return navigateTo(`/dashboard/my-courses?enrolled=true&course_id=${course.value.id}`)
// Use object syntax for robust query param handling
const targetId = route.params.id || course.value?.id
return navigateTo({
path: '/dashboard/my-courses',
query: {
enrolled: 'true',
course_id: String(targetId)
}
})
} else {
// error alert ( Toast notification)
alert(res.error || 'Failed to enroll')

View file

@ -120,6 +120,12 @@ const downloadCertificate = async (course: any) => {
downloadingCourseId.value = null
}
}
const validCourseId = computed(() => {
const cid = route.query.course_id
if (!cid || cid === 'undefined' || cid === 'null' || cid === 'NaN') return null
return cid
})
</script>
<template>
@ -195,14 +201,25 @@ const downloadCertificate = async (course: any) => {
<p class="text-slate-500 dark:text-slate-400 mb-8">{{ $t('enrollment.successDesc') }}</p>
<div class="flex flex-col gap-3">
<q-btn
:to="`/classroom/learning?course_id=${route.query.course_id}`"
v-if="validCourseId"
:to="`/classroom/learning?course_id=${validCourseId}`"
unelevated
rounded
color="primary"
class="w-full py-3 text-lg font-bold shadow-lg"
:label="$t('enrollment.startNow')"
/>
<q-btn
v-else
unelevated
rounded
color="primary"
class="w-full py-3 text-lg font-bold shadow-lg"
:label="$t('common.close')"
@click="showEnrollModal = false"
/>
<q-btn
v-if="validCourseId"
flat
rounded
color="grey-7"

View file

@ -380,16 +380,7 @@ onMounted(() => {
<div>
<div class="flex justify-between items-end mb-1">
<label class="text-xs font-bold text-slate-500 dark:text-slate-400 ml-1 uppercase">{{ $t('profile.email') }}</label>
<q-btn
flat dense
color="primary"
size="sm"
no-caps
icon="mark_email_read"
:label="$t('profile.verifyEmail') || 'ยืนยันอีเมล'"
@click="handleSendVerifyEmail"
:loading="isSendingVerify"
/>
</div>
<q-input
v-model="userData.email"

View file

@ -87,62 +87,116 @@ const resetPassword = async () => {
</script>
<template>
<div class="card" style="width: 100%; max-width: 440px;">
<!-- Loading Overlay -->
<LoadingSpinner v-if="isLoading" full-page text="กำลังดำเนินการ..." />
<!-- Header -->
<div class="flex flex-col items-center mb-6">
<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-muted text-sm">งรหสผานใหมของค</p>
<div class="relative min-h-screen w-full flex items-center justify-center p-4 overflow-hidden bg-slate-50 transition-colors">
<!-- ==========================================
BACKGROUND EFFECTS (Light Mode Only)
========================================== -->
<div class="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<div class="absolute inset-0 bg-gradient-to-br from-white via-slate-50 to-blue-50/50"></div>
<div class="absolute top-[-10%] right-[-5%] w-[500px] h-[500px] rounded-full bg-blue-100/50 blur-[100px] animate-pulse-slow"/>
<div class="absolute bottom-[-10%] left-[-5%] w-[500px] h-[500px] rounded-full bg-indigo-100/50 blur-[100px] animate-pulse-slow" style="animation-delay: 3s;"/>
</div>
<!-- RESET PASSWORD FORM -->
<form @submit.prevent="resetPassword">
<h3 class="font-bold mb-4">งรหสผานใหม</h3>
<!-- ==========================================
RESET PASSWORD CARD
========================================== -->
<div class="w-full max-w-[460px] relative z-10 slide-up">
<!-- New Password -->
<FormInput
:model-value="resetForm.password"
label="รหัสผ่านใหม่"
type="password"
placeholder="••••••••"
:error="errors.password"
required
@update:model-value="(val) => handlePasswordInput('password', val)"
/>
<!-- Confirm New Password -->
<FormInput
v-model="resetForm.confirmPassword"
label="ยืนยันรหัสผ่านใหม่"
type="password"
placeholder="••••••••"
:error="errors.confirmPassword"
required
@update:model-value="clearFieldError('confirmPassword')"
/>
<button type="submit" class="btn btn-primary w-full mb-4" :disabled="isLoading">
<LoadingSpinner v-if="isLoading" size="sm" />
<span v-else>บันทึกรหัสผ่านใหม่</span>
</button>
<!-- Header / Logo -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-tr from-blue-600 to-indigo-600 text-white shadow-lg shadow-blue-600/20 mb-6">
<span class="font-black text-2xl">E</span>
</div>
<h1 class="text-3xl font-black text-slate-900 mb-2">งรหสผานใหม</h1>
<p class="text-slate-600 text-base">กรณากรอกรหสผานใหมณตองการ</p>
</div>
<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 class="bg-white rounded-[2rem] p-8 md:p-10 shadow-xl shadow-slate-200/50 border border-slate-100 relative overflow-hidden">
<!-- Form -->
<form @submit.prevent="resetPassword" class="flex flex-col gap-6">
<!-- New Password -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหสผานใหม <span class="text-red-500">*</span></label>
<div class="relative group">
<div class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 transition-colors group-focus-within:text-blue-500 pointer-events-none">
<span class="material-icons text-xl">lock</span>
</div>
<input
:value="resetForm.password"
@input="(e) => handlePasswordInput('password', (e.target as HTMLInputElement).value)"
type="password"
class="w-full h-12 pl-12 pr-4 rounded-xl bg-slate-50 border border-slate-200 text-slate-900 placeholder-slate-400 text-base focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all font-medium"
placeholder="อย่างน้อย 8 ตัวอักษร"
:class="{'border-red-500 focus:ring-red-500/20 focus:border-red-500': errors.password}"
/>
</div>
<span v-if="errors.password" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.password }}</span>
</div>
<!-- Confirm Password -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">นยนรหสผานใหม <span class="text-red-500">*</span></label>
<div class="relative group">
<div class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 transition-colors group-focus-within:text-blue-500 pointer-events-none">
<span class="material-icons text-xl">lock_clock</span>
</div>
<input
v-model="resetForm.confirmPassword"
@input="clearFieldError('confirmPassword')"
type="password"
class="w-full h-12 pl-12 pr-4 rounded-xl bg-slate-50 border border-slate-200 text-slate-900 placeholder-slate-400 text-base focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all font-medium"
placeholder="กรอกรหัสผ่านอีกครั้ง"
:class="{'border-red-500 focus:ring-red-500/20 focus:border-red-500': errors.confirmPassword}"
/>
</div>
<span v-if="errors.confirmPassword" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.confirmPassword }}</span>
</div>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading"
class="w-full py-3.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-lg font-bold shadow-lg shadow-blue-600/30 transform active:scale-[0.98] transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed mt-2"
>
<span v-if="!isLoading">บันทึกรหัสผ่านใหม่</span>
<div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
</button>
</form>
</div>
<div class="text-center pt-4 border-t border-gray-100">
<NuxtLink to="/" class="text-sm text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 transition-colors flex items-center justify-center gap-1">
<span></span> กลบไปหนาหล
</NuxtLink>
</div>
</form>
<!-- Back Link -->
<div class="mt-8 text-center text-slate-500">
<NuxtLink to="/auth/login" class="inline-flex items-center gap-2 text-sm font-medium hover:text-slate-800 transition-colors group px-4 py-2 rounded-lg hover:bg-white/50">
<span class="group-hover:-translate-x-1 transition-transform"></span> กลบไปหนาเขาสระบบ
</NuxtLink>
</div>
</div>
</div>
</template>
<style scoped>
/* Animations */
@keyframes pulse-slow {
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.15); }
}
.animate-pulse-slow {
animation: pulse-slow 8s ease-in-out infinite;
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.slide-up {
animation: slide-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.slide-up-sm {
animation: slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
</style>