feat: Implement authentication system with token refresh and initial instructor dashboard with course management.
This commit is contained in:
parent
0eb9b522f6
commit
ab3124628c
11 changed files with 1053 additions and 93 deletions
80
frontend_management/composables/useAuthFetch.ts
Normal file
80
frontend_management/composables/useAuthFetch.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { authService } from '~/services/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom fetch composable that handles automatic token refresh
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Get token from cookie
|
||||||
|
* 2. Make API request with token
|
||||||
|
* 3. If 401 error (token expired):
|
||||||
|
* - Try to refresh token using refreshToken
|
||||||
|
* - If refresh successful, retry original request
|
||||||
|
* - If refresh fails, redirect to login
|
||||||
|
*/
|
||||||
|
export const useAuthFetch = () => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const authFetch = async <T>(
|
||||||
|
url: string,
|
||||||
|
options: {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
body?: any;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> => {
|
||||||
|
const tokenCookie = useCookie('token');
|
||||||
|
const refreshTokenCookie = useCookie('refreshToken');
|
||||||
|
|
||||||
|
const makeRequest = async (token: string | null) => {
|
||||||
|
return await $fetch<T>(url, {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: options.body
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try request with current token
|
||||||
|
return await makeRequest(tokenCookie.value ?? null);
|
||||||
|
} catch (error: any) {
|
||||||
|
// If 401 and we have refresh token, try to refresh
|
||||||
|
if (error.response?.status === 401 && refreshTokenCookie.value) {
|
||||||
|
console.log('Token expired, attempting refresh...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Refresh token
|
||||||
|
const newTokens = await authService.refreshToken(refreshTokenCookie.value);
|
||||||
|
console.log('Token refreshed successfully');
|
||||||
|
|
||||||
|
// Update cookies
|
||||||
|
tokenCookie.value = newTokens.token;
|
||||||
|
refreshTokenCookie.value = newTokens.refreshToken;
|
||||||
|
|
||||||
|
// Retry original request with new token
|
||||||
|
return await makeRequest(newTokens.token);
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error('Token refresh failed, redirecting to login');
|
||||||
|
|
||||||
|
// Clear cookies
|
||||||
|
tokenCookie.value = null;
|
||||||
|
refreshTokenCookie.value = null;
|
||||||
|
useCookie('user').value = null;
|
||||||
|
|
||||||
|
// Redirect to login
|
||||||
|
router.push('/login');
|
||||||
|
throw new Error('Session expired. Please login again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other errors, just throw
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { authFetch };
|
||||||
|
};
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<q-card
|
<q-card
|
||||||
v-for="chapter in course.chapters"
|
v-for="chapter in sortedChapters"
|
||||||
:key="chapter.id"
|
:key="chapter.id"
|
||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
|
|
@ -125,7 +125,7 @@
|
||||||
<!-- Lessons -->
|
<!-- Lessons -->
|
||||||
<q-list separator class="border-t">
|
<q-list separator class="border-t">
|
||||||
<q-item
|
<q-item
|
||||||
v-for="lesson in chapter.lessons"
|
v-for="lesson in getSortedLessons(chapter)"
|
||||||
:key="lesson.id"
|
:key="lesson.id"
|
||||||
class="py-3"
|
class="py-3"
|
||||||
>
|
>
|
||||||
|
|
@ -204,6 +204,11 @@ const totalLessons = computed(() => {
|
||||||
return course.value.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
|
return course.value.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sortedChapters = computed(() => {
|
||||||
|
if (!course.value) return [];
|
||||||
|
return course.value.chapters.slice().sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
});
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const fetchCourse = async () => {
|
const fetchCourse = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
@ -246,6 +251,10 @@ const getChapterDuration = (chapter: ChapterResponse) => {
|
||||||
return chapter.lessons.reduce((sum, l) => sum + l.duration_minutes, 0);
|
return chapter.lessons.reduce((sum, l) => sum + l.duration_minutes, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSortedLessons = (chapter: ChapterResponse) => {
|
||||||
|
return chapter.lessons.slice().sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
};
|
||||||
|
|
||||||
const getLessonIcon = (type: string) => {
|
const getLessonIcon = (type: string) => {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
VIDEO: 'play_circle',
|
VIDEO: 'play_circle',
|
||||||
|
|
|
||||||
571
frontend_management/pages/instructor/courses/[id]/structure.vue
Normal file
571
frontend_management/pages/instructor/courses/[id]/structure.vue
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
icon="arrow_back"
|
||||||
|
@click="navigateTo(`/instructor/courses/${courseId}`)"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-2xl font-bold text-primary-600">จัดการโครงสร้างหลักสูตร</h1>
|
||||||
|
<p class="text-gray-600 mt-1">เพิ่ม แก้ไข และจัดเรียงบทเรียน</p>
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="add"
|
||||||
|
label="เพิ่มบท"
|
||||||
|
@click="openChapterDialog()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-20">
|
||||||
|
<q-spinner-dots size="50px" color="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="chapters.length === 0" class="bg-white rounded-xl shadow-sm p-10 text-center">
|
||||||
|
<q-icon name="folder_open" size="60px" color="grey-4" class="mb-4" />
|
||||||
|
<p class="text-gray-500 text-lg mb-4">ยังไม่มีบทเรียน</p>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="add"
|
||||||
|
label="เพิ่มบทแรก"
|
||||||
|
@click="openChapterDialog()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapters List -->
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<!-- Drop indicator before first chapter -->
|
||||||
|
<div
|
||||||
|
v-if="dragOverChapter && dragOverPosition === 'before' && sortedChapters.findIndex(ch => ch.id === dragOverChapter?.id) === 0"
|
||||||
|
class="h-1.5 bg-primary rounded-full mb-2 shadow-lg shadow-primary/50 transition-all animate-pulse"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-card
|
||||||
|
v-for="(chapter, chapterIndex) in sortedChapters"
|
||||||
|
:key="chapter.id"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
class="rounded-lg relative"
|
||||||
|
:class="{ 'opacity-50': draggedChapter?.id === chapter.id }"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart(chapter, $event)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
@dragover.prevent="onDragOver(chapter, $event)"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop.prevent="onDrop(chapter)"
|
||||||
|
>
|
||||||
|
<!-- Drop indicator line at top -->
|
||||||
|
<div
|
||||||
|
v-if="dragOverChapter?.id === chapter.id && dragOverPosition === 'before'"
|
||||||
|
class="absolute -top-2 left-0 right-0 h-1.5 bg-primary rounded-full z-10 shadow-lg shadow-primary/50 transition-all animate-pulse"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Drop indicator line at bottom -->
|
||||||
|
<div
|
||||||
|
v-if="dragOverChapter?.id === chapter.id && dragOverPosition === 'after'"
|
||||||
|
class="absolute -bottom-2 left-0 right-0 h-1.5 bg-primary rounded-full z-10 shadow-lg shadow-primary/50 transition-all animate-pulse"
|
||||||
|
/>
|
||||||
|
<!-- Chapter Header -->
|
||||||
|
<q-card-section class="bg-gray-50">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<q-icon name="drag_indicator" class="cursor-move text-gray-400" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-semibold text-gray-900">
|
||||||
|
บทที่ {{ chapter.sort_order }}: {{ chapter.title.th }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ chapter.lessons.length }} บทเรียน
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-btn flat dense icon="add" color="primary" @click="openLessonDialog(chapter)">
|
||||||
|
<q-tooltip>เพิ่มบทเรียน</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense icon="edit" color="grey" @click="openChapterDialog(chapter)">
|
||||||
|
<q-tooltip>แก้ไข</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense icon="delete" color="negative" @click="confirmDeleteChapter(chapter)">
|
||||||
|
<q-tooltip>ลบ</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:icon="chapter.expanded ? 'expand_less' : 'expand_more'"
|
||||||
|
@click="chapter.expanded = !chapter.expanded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Lessons List -->
|
||||||
|
<q-slide-transition>
|
||||||
|
<div v-show="chapter.expanded !== false">
|
||||||
|
<q-list separator>
|
||||||
|
<q-item
|
||||||
|
v-for="(lesson, lessonIndex) in chapter.lessons"
|
||||||
|
:key="lesson.id"
|
||||||
|
class="py-3"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="drag_indicator" class="cursor-move text-gray-300" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon
|
||||||
|
:name="getLessonIcon(lesson.type)"
|
||||||
|
:color="getLessonIconColor(lesson.type)"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>
|
||||||
|
{{ chapterIndex + 1 }}.{{ lessonIndex + 1 }} {{ lesson.title.th }}
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
{{ getLessonTypeLabel(lesson.type) }} · {{ lesson.duration_minutes }} นาที
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<q-btn flat dense icon="edit" size="sm" @click="openLessonDialog(chapter, lesson)">
|
||||||
|
<q-tooltip>แก้ไข</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense icon="delete" size="sm" color="negative" @click="confirmDeleteLesson(chapter, lesson)">
|
||||||
|
<q-tooltip>ลบ</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<!-- Empty Lessons -->
|
||||||
|
<div v-if="chapter.lessons.length === 0" class="p-4 text-center text-gray-400">
|
||||||
|
ยังไม่มีบทเรียนในบทนี้
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-slide-transition>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Drop indicator after last chapter -->
|
||||||
|
<div
|
||||||
|
v-if="dragOverChapter && dragOverPosition === 'after' && sortedChapters.findIndex(ch => ch.id === dragOverChapter?.id) === sortedChapters.length - 1"
|
||||||
|
class="h-1.5 bg-primary rounded-full mt-2 shadow-lg shadow-primary/50 transition-all animate-pulse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapter Dialog -->
|
||||||
|
<q-dialog v-model="chapterDialog" persistent>
|
||||||
|
<q-card style="min-width: 450px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">{{ editingChapter ? 'แก้ไขบท' : 'เพิ่มบทใหม่' }}</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense @click="chapterDialog = false" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-form @submit="saveChapter">
|
||||||
|
<q-input
|
||||||
|
v-model="chapterForm.title.th"
|
||||||
|
label="ชื่อบท (ภาษาไทย) *"
|
||||||
|
outlined
|
||||||
|
:rules="[val => !!val || 'กรุณากรอกชื่อบท']"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-model="chapterForm.title.en"
|
||||||
|
label="ชื่อบท (English)"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-model="chapterForm.description.th"
|
||||||
|
label="คำอธิบาย (ภาษาไทย)"
|
||||||
|
type="textarea"
|
||||||
|
outlined
|
||||||
|
autogrow
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-model="chapterForm.description.en"
|
||||||
|
label="คำอธิบาย (English)"
|
||||||
|
type="textarea"
|
||||||
|
outlined
|
||||||
|
autogrow
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
|
<q-btn flat label="ยกเลิก" color="grey-7" @click="chapterDialog = false" />
|
||||||
|
<q-btn type="submit" label="บันทึก" color="primary" :loading="saving" />
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- Lesson Dialog -->
|
||||||
|
<q-dialog v-model="lessonDialog" persistent>
|
||||||
|
<q-card style="min-width: 500px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">{{ editingLesson ? 'แก้ไขบทเรียน' : 'เพิ่มบทเรียน' }}</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense @click="lessonDialog = false" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-form @submit="saveLesson">
|
||||||
|
<q-input
|
||||||
|
v-model="lessonForm.title.th"
|
||||||
|
label="ชื่อบทเรียน (ภาษาไทย) *"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
:rules="[val => !!val || 'กรุณากรอกชื่อบทเรียน']"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-model="lessonForm.title.en"
|
||||||
|
label="ชื่อบทเรียน (English)"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-select
|
||||||
|
v-model="lessonForm.type"
|
||||||
|
:options="lessonTypeOptions"
|
||||||
|
label="ประเภท *"
|
||||||
|
outlined
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model.number="lessonForm.duration_minutes"
|
||||||
|
label="ระยะเวลา (นาที)"
|
||||||
|
type="number"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-if="lessonForm.type !== 'QUIZ'"
|
||||||
|
v-model="lessonForm.content.th"
|
||||||
|
label="เนื้อหา (ภาษาไทย)"
|
||||||
|
type="textarea"
|
||||||
|
outlined
|
||||||
|
autogrow
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
|
<q-btn flat label="ยกเลิก" color="grey-7" @click="lessonDialog = false" />
|
||||||
|
<q-btn type="submit" label="บันทึก" color="primary" :loading="saving" />
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { instructorService, type ChapterResponse, type LessonResponse } from '~/services/instructor.service';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'instructor',
|
||||||
|
middleware: ['auth']
|
||||||
|
});
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const route = useRoute();
|
||||||
|
const courseId = computed(() => parseInt(route.params.id as string));
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
const chapters = ref<(ChapterResponse & { expanded?: boolean })[]>([]);
|
||||||
|
|
||||||
|
// Computed: sorted chapters for display
|
||||||
|
const sortedChapters = computed(() => {
|
||||||
|
return chapters.value.slice().sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
const chapterDialog = ref(false);
|
||||||
|
const lessonDialog = ref(false);
|
||||||
|
const editingChapter = ref<ChapterResponse | null>(null);
|
||||||
|
const editingLesson = ref<LessonResponse | null>(null);
|
||||||
|
const selectedChapter = ref<ChapterResponse | null>(null);
|
||||||
|
|
||||||
|
// Drag and Drop
|
||||||
|
const draggedChapter = ref<ChapterResponse | null>(null);
|
||||||
|
const dragOverChapter = ref<ChapterResponse | null>(null);
|
||||||
|
const dragOverPosition = ref<'before' | 'after'>('before');
|
||||||
|
|
||||||
|
const onDragStart = (chapter: ChapterResponse, event: DragEvent) => {
|
||||||
|
draggedChapter.value = chapter;
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = () => {
|
||||||
|
draggedChapter.value = null;
|
||||||
|
dragOverChapter.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragOver = (chapter: ChapterResponse, event: DragEvent) => {
|
||||||
|
if (draggedChapter.value && draggedChapter.value.id !== chapter.id) {
|
||||||
|
dragOverChapter.value = chapter;
|
||||||
|
|
||||||
|
// Determine whether we're hovering over the top half (before) or bottom half (after)
|
||||||
|
const el = event.currentTarget as HTMLElement | null;
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const y = event.clientY - rect.top;
|
||||||
|
dragOverPosition.value = y < rect.height / 2 ? 'before' : 'after';
|
||||||
|
} else {
|
||||||
|
dragOverPosition.value = 'before';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragLeave = () => {
|
||||||
|
dragOverChapter.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = async (targetChapter: ChapterResponse) => {
|
||||||
|
if (!draggedChapter.value || draggedChapter.value.id === targetChapter.id) {
|
||||||
|
onDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Insert behavior: move dragged chapter to target position (before/after target gap)
|
||||||
|
const sorted = chapters.value.slice().sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
const fromIndex = sorted.findIndex(ch => ch.id === draggedChapter.value!.id);
|
||||||
|
const targetIndex = sorted.findIndex(ch => ch.id === targetChapter.id);
|
||||||
|
|
||||||
|
if (fromIndex === -1 || targetIndex === -1) {
|
||||||
|
throw new Error('Chapter not found in list');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide insert before/after based on hover position (top half => before, bottom half => after)
|
||||||
|
const desiredBeforeRemoval = dragOverPosition.value === 'before' ? targetIndex : targetIndex + 1;
|
||||||
|
|
||||||
|
// Remove dragged item then insert at the adjusted index AFTER removal
|
||||||
|
const [moved] = sorted.splice(fromIndex, 1);
|
||||||
|
let insertIndex = fromIndex < desiredBeforeRemoval ? desiredBeforeRemoval - 1 : desiredBeforeRemoval;
|
||||||
|
insertIndex = Math.max(0, Math.min(insertIndex, sorted.length));
|
||||||
|
sorted.splice(insertIndex, 0, moved);
|
||||||
|
|
||||||
|
// Re-number sort_order for UI consistency (server will also normalize)
|
||||||
|
const expandedMap = new Map(chapters.value.map(ch => [ch.id, ch.expanded]));
|
||||||
|
const optimistic = sorted.map((ch, idx) => ({
|
||||||
|
...ch,
|
||||||
|
sort_order: idx + 1,
|
||||||
|
expanded: expandedMap.get(ch.id) ?? true
|
||||||
|
}));
|
||||||
|
|
||||||
|
chapters.value = optimistic;
|
||||||
|
|
||||||
|
// Update only the dragged chapter's sort_order; backend should shift others accordingly
|
||||||
|
await instructorService.reorderChapter(courseId.value, moved.id, insertIndex + 1);
|
||||||
|
|
||||||
|
$q.notify({ type: 'positive', message: 'เรียงลำดับใหม่สำเร็จ', position: 'top' });
|
||||||
|
fetchChapters();
|
||||||
|
} catch (error) {
|
||||||
|
// Revert via re-fetch to guarantee consistency
|
||||||
|
fetchChapters();
|
||||||
|
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||||
|
} finally {
|
||||||
|
onDragEnd();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
const chapterForm = ref({
|
||||||
|
title: { th: '', en: '' },
|
||||||
|
description: { th: '', en: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const lessonForm = ref({
|
||||||
|
title: { th: '', en: '' },
|
||||||
|
content: { th: '', en: '' },
|
||||||
|
type: 'VIDEO' as 'VIDEO' | 'QUIZ' | 'DOCUMENT',
|
||||||
|
duration_minutes: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
const lessonTypeOptions = [
|
||||||
|
{ label: 'วิดีโอ', value: 'VIDEO' },
|
||||||
|
{ label: 'แบบทดสอบ', value: 'QUIZ' },
|
||||||
|
{ label: 'เอกสาร', value: 'DOCUMENT' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchChapters = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await instructorService.getChapters(courseId.value);
|
||||||
|
chapters.value = data.map(ch => ({ ...ch, expanded: true }));
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLessonIcon = (type: string) => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
VIDEO: 'play_circle',
|
||||||
|
QUIZ: 'quiz',
|
||||||
|
DOCUMENT: 'description'
|
||||||
|
};
|
||||||
|
return icons[type] || 'article';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLessonIconColor = (type: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
VIDEO: 'primary',
|
||||||
|
QUIZ: 'orange',
|
||||||
|
DOCUMENT: 'grey'
|
||||||
|
};
|
||||||
|
return colors[type] || 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLessonTypeLabel = (type: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
VIDEO: 'วิดีโอ',
|
||||||
|
QUIZ: 'แบบทดสอบ',
|
||||||
|
DOCUMENT: 'เอกสาร'
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chapter CRUD
|
||||||
|
const openChapterDialog = (chapter?: ChapterResponse) => {
|
||||||
|
if (chapter) {
|
||||||
|
editingChapter.value = chapter;
|
||||||
|
chapterForm.value = {
|
||||||
|
title: { ...chapter.title },
|
||||||
|
description: { ...chapter.description }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
editingChapter.value = null;
|
||||||
|
chapterForm.value = {
|
||||||
|
title: { th: '', en: '' },
|
||||||
|
description: { th: '', en: '' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
chapterDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveChapter = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (editingChapter.value) {
|
||||||
|
await instructorService.updateChapter(courseId.value, editingChapter.value.id, chapterForm.value);
|
||||||
|
$q.notify({ type: 'positive', message: 'แก้ไขบทสำเร็จ', position: 'top' });
|
||||||
|
} else {
|
||||||
|
// Calculate sort_order for new chapter
|
||||||
|
const sortOrder = chapters.value.length + 1;
|
||||||
|
await instructorService.createChapter(courseId.value, {
|
||||||
|
...chapterForm.value,
|
||||||
|
sort_order: sortOrder
|
||||||
|
});
|
||||||
|
$q.notify({ type: 'positive', message: 'เพิ่มบทสำเร็จ', position: 'top' });
|
||||||
|
}
|
||||||
|
chapterDialog.value = false;
|
||||||
|
fetchChapters();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({ type: 'negative', message: 'เกิดข้อผิดพลาด', position: 'top' });
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteChapter = (chapter: ChapterResponse) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'ยืนยันการลบ',
|
||||||
|
message: `ต้องการลบบท "${chapter.title.th}" หรือไม่? บทเรียนทั้งหมดในบทนี้จะถูกลบด้วย`,
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
await instructorService.deleteChapter(courseId.value, chapter.id);
|
||||||
|
$q.notify({ type: 'positive', message: 'ลบบทสำเร็จ', position: 'top' });
|
||||||
|
fetchChapters();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({ type: 'negative', message: 'ไม่สามารถลบได้', position: 'top' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lesson CRUD
|
||||||
|
const openLessonDialog = (chapter: ChapterResponse, lesson?: LessonResponse) => {
|
||||||
|
selectedChapter.value = chapter;
|
||||||
|
if (lesson) {
|
||||||
|
editingLesson.value = lesson;
|
||||||
|
lessonForm.value = {
|
||||||
|
title: { ...lesson.title },
|
||||||
|
content: lesson.content ? { ...lesson.content } : { th: '', en: '' },
|
||||||
|
type: lesson.type,
|
||||||
|
duration_minutes: lesson.duration_minutes
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
editingLesson.value = null;
|
||||||
|
lessonForm.value = {
|
||||||
|
title: { th: '', en: '' },
|
||||||
|
content: { th: '', en: '' },
|
||||||
|
type: 'VIDEO',
|
||||||
|
duration_minutes: 10
|
||||||
|
};
|
||||||
|
}
|
||||||
|
lessonDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLesson = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (editingLesson.value) {
|
||||||
|
// TODO: Call updateLesson API
|
||||||
|
$q.notify({ type: 'positive', message: 'แก้ไขบทเรียนสำเร็จ', position: 'top' });
|
||||||
|
} else {
|
||||||
|
// TODO: Call createLesson API
|
||||||
|
$q.notify({ type: 'positive', message: 'เพิ่มบทเรียนสำเร็จ', position: 'top' });
|
||||||
|
}
|
||||||
|
lessonDialog.value = false;
|
||||||
|
fetchChapters();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({ type: 'negative', message: 'เกิดข้อผิดพลาด', position: 'top' });
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteLesson = (chapter: ChapterResponse, lesson: LessonResponse) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'ยืนยันการลบ',
|
||||||
|
message: `ต้องการลบบทเรียน "${lesson.title.th}" หรือไม่?`,
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
// TODO: Call deleteLesson API
|
||||||
|
$q.notify({ type: 'positive', message: 'ลบบทเรียนสำเร็จ', position: 'top' });
|
||||||
|
fetchChapters();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({ type: 'negative', message: 'ไม่สามารถลบได้', position: 'top' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchChapters();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -112,8 +112,14 @@
|
||||||
>
|
>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="w-20 h-16 bg-primary-100 rounded-lg flex items-center justify-center text-3xl">
|
<div class="w-20 h-16 bg-primary-100 rounded-lg flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||||
{{ course.icon }}
|
<img
|
||||||
|
v-if="course.thumbnail"
|
||||||
|
:src="course.thumbnail"
|
||||||
|
:alt="course.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-3xl">{{ course.icon }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-semibold text-gray-900">{{ course.title }}</div>
|
<div class="font-semibold text-gray-900">{{ course.title }}</div>
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,18 @@ const $q = useQuasar();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Check if already logged in, redirect to appropriate dashboard
|
||||||
|
onMounted(() => {
|
||||||
|
if (authStore.isAuthenticated && authStore.user) {
|
||||||
|
const role = authStore.user.role;
|
||||||
|
if (role === 'ADMIN') {
|
||||||
|
navigateTo('/admin');
|
||||||
|
} else if (role === 'INSTRUCTOR') {
|
||||||
|
navigateTo('/instructor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Login form
|
// Login form
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
export default defineNuxtPlugin({
|
export default defineNuxtPlugin({
|
||||||
name: 'auth-restore',
|
name: 'auth-restore',
|
||||||
parallel: false,
|
parallel: false,
|
||||||
setup() {
|
async setup() {
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
// Restore auth state from cookies on app initialization
|
// Restore auth state from cookies on app initialization
|
||||||
// useCookie works on both server and client
|
// Now async - will attempt to refresh token if needed
|
||||||
authStore.checkAuth();
|
await authStore.checkAuth();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,33 @@ export const authService = {
|
||||||
baseURL: config.public.apiBaseUrl as string,
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
body: data
|
body: data
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshToken(currentRefreshToken: string): Promise<{ token: string; refreshToken: string }> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
// Mock: return new tokens
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return {
|
||||||
|
token: 'mock-new-jwt-token-' + Date.now(),
|
||||||
|
refreshToken: 'mock-new-refresh-token-' + Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentRefreshToken) {
|
||||||
|
throw new Error('No refresh token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real API
|
||||||
|
const response = await $fetch<{ token: string; refreshToken: string }>('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
body: { refreshToken: currentRefreshToken }
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,45 @@ const getAuthToken = (): string => {
|
||||||
return tokenCookie.value || '';
|
return tokenCookie.value || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function for making authenticated requests with auto refresh
|
||||||
|
const authRequest = async <T>(
|
||||||
|
url: string,
|
||||||
|
options: {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
|
body?: any;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
|
||||||
|
const makeRequest = async (token: string) => {
|
||||||
|
return await $fetch<T>(url, {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: options.body
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
return await makeRequest(token);
|
||||||
|
} catch (error: any) {
|
||||||
|
// If 401, try to refresh token
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const { handleUnauthorized } = await import('~/utils/authFetch');
|
||||||
|
const newToken = await handleUnauthorized();
|
||||||
|
if (newToken) {
|
||||||
|
return await makeRequest(newToken);
|
||||||
|
}
|
||||||
|
// Redirect to login
|
||||||
|
navigateTo('/login');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Mock courses data
|
// Mock courses data
|
||||||
const MOCK_COURSES: CourseResponse[] = [
|
const MOCK_COURSES: CourseResponse[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -118,14 +157,7 @@ export const instructorService = {
|
||||||
return MOCK_COURSES;
|
return MOCK_COURSES;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getAuthToken();
|
const response = await authRequest<CoursesListResponse>('/api/instructors/courses');
|
||||||
const response = await $fetch<CoursesListResponse>('/api/instructors/courses', {
|
|
||||||
baseURL: config.public.apiBaseUrl as string,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -146,27 +178,16 @@ export const instructorService = {
|
||||||
} as CourseResponse;
|
} as CourseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getAuthToken();
|
|
||||||
|
|
||||||
// Clean data - remove empty thumbnail_url
|
// Clean data - remove empty thumbnail_url
|
||||||
const cleanedData = { ...data };
|
const cleanedData = { ...data };
|
||||||
if (!cleanedData.thumbnail_url) {
|
if (!cleanedData.thumbnail_url) {
|
||||||
delete cleanedData.thumbnail_url;
|
delete cleanedData.thumbnail_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('=== CREATE COURSE DEBUG ===');
|
const response = await authRequest<{ code: number; data: CourseResponse }>('/api/instructors/courses', {
|
||||||
console.log('Body:', JSON.stringify({ data: cleanedData }, null, 2));
|
|
||||||
console.log('===========================');
|
|
||||||
|
|
||||||
const response = await $fetch<{ code: number; data: CourseResponse }>('/api/instructors/courses', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
baseURL: config.public.apiBaseUrl as string,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: { data: cleanedData }
|
body: { data: cleanedData }
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -179,14 +200,7 @@ export const instructorService = {
|
||||||
return MOCK_COURSE_DETAIL;
|
return MOCK_COURSE_DETAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getAuthToken();
|
const response = await authRequest<{ code: number; data: CourseDetailResponse }>(`/api/instructors/courses/${courseId}`);
|
||||||
const response = await $fetch<{ code: number; data: CourseDetailResponse }>(`/api/instructors/courses/${courseId}`, {
|
|
||||||
baseURL: config.public.apiBaseUrl as string,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -204,23 +218,10 @@ export const instructorService = {
|
||||||
} as CourseResponse;
|
} as CourseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getAuthToken();
|
const response = await authRequest<{ code: number; data: CourseResponse }>(`/api/instructors/courses/${courseId}`, {
|
||||||
|
|
||||||
// Debug log
|
|
||||||
console.log('=== UPDATE COURSE DEBUG ===');
|
|
||||||
console.log('URL:', `${config.public.apiBaseUrl}/api/instructors/courses/${courseId}`);
|
|
||||||
console.log('Body:', JSON.stringify({ data }, null, 2));
|
|
||||||
console.log('===========================');
|
|
||||||
|
|
||||||
const response = await $fetch<{ code: number; data: CourseResponse }>(`/api/instructors/courses/${courseId}`, {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
baseURL: config.public.apiBaseUrl as string,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: { data }
|
body: { data }
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -233,14 +234,7 @@ export const instructorService = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getAuthToken();
|
await authRequest(`/api/instructors/courses/${courseId}`, { method: 'DELETE' });
|
||||||
await $fetch(`/api/instructors/courses/${courseId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
baseURL: config.public.apiBaseUrl as string,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async sendForReview(courseId: number): Promise<void> {
|
async sendForReview(courseId: number): Promise<void> {
|
||||||
|
|
@ -252,14 +246,93 @@ export const instructorService = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getAuthToken();
|
await authRequest(`/api/instructors/courses/send-review/${courseId}`, { method: 'POST' });
|
||||||
await $fetch(`/api/instructors/courses/send-review/${courseId}`, {
|
},
|
||||||
method: 'POST',
|
|
||||||
baseURL: config.public.apiBaseUrl as string,
|
async getChapters(courseId: number): Promise<ChapterResponse[]> {
|
||||||
headers: {
|
const config = useRuntimeConfig();
|
||||||
Authorization: `Bearer ${token}`
|
const useMockData = config.public.useMockData as boolean;
|
||||||
}
|
|
||||||
});
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return MOCK_COURSE_DETAIL.chapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authRequest<{ code: number; data: ChapterResponse[]; total: number }>(
|
||||||
|
`/api/instructors/courses/${courseId}/chapters`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createChapter(courseId: number, data: CreateChapterRequest): Promise<ChapterResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return {
|
||||||
|
...MOCK_COURSE_DETAIL.chapters[0],
|
||||||
|
id: Date.now(),
|
||||||
|
...data,
|
||||||
|
lessons: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authRequest<{ code: number; data: ChapterResponse }>(
|
||||||
|
`/api/instructors/courses/${courseId}/chapters`,
|
||||||
|
{ method: 'POST', body: data }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateChapter(courseId: number, chapterId: number, data: CreateChapterRequest): Promise<ChapterResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return {
|
||||||
|
...MOCK_COURSE_DETAIL.chapters[0],
|
||||||
|
id: chapterId,
|
||||||
|
...data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authRequest<{ code: number; data: ChapterResponse }>(
|
||||||
|
`/api/instructors/courses/${courseId}/chapters/${chapterId}`,
|
||||||
|
{ method: 'PUT', body: data }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteChapter(courseId: number, chapterId: number): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await authRequest(
|
||||||
|
`/api/instructors/courses/${courseId}/chapters/${chapterId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async reorderChapter(courseId: number, chapterId: number, sortOrder: number): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await authRequest(
|
||||||
|
`/api/instructors/courses/${courseId}/chapters/${chapterId}/reorder`,
|
||||||
|
{ method: 'PUT', body: { sort_order: sortOrder } }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -327,6 +400,13 @@ export interface QuizResponse {
|
||||||
show_answers_after_completion: boolean;
|
show_answers_after_completion: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateChapterRequest {
|
||||||
|
title: { th: string; en: string };
|
||||||
|
description: { th: string; en: string };
|
||||||
|
sort_order?: number;
|
||||||
|
is_published?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Mock course detail
|
// Mock course detail
|
||||||
const MOCK_COURSE_DETAIL: CourseDetailResponse = {
|
const MOCK_COURSE_DETAIL: CourseDetailResponse = {
|
||||||
...MOCK_COURSES[0],
|
...MOCK_COURSES[0],
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,12 @@ export const useAuthStore = defineStore('auth', {
|
||||||
userCookie.value = null;
|
userCookie.value = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth() {
|
async checkAuth() {
|
||||||
const tokenCookie = useCookie('token');
|
const tokenCookie = useCookie('token');
|
||||||
const userCookie = useCookie('user');
|
const userCookie = useCookie('user');
|
||||||
|
const refreshTokenCookie = useCookie('refreshToken');
|
||||||
|
|
||||||
|
// Case 1: Have token and user - restore auth state
|
||||||
if (tokenCookie.value && userCookie.value) {
|
if (tokenCookie.value && userCookie.value) {
|
||||||
this.token = tokenCookie.value;
|
this.token = tokenCookie.value;
|
||||||
try {
|
try {
|
||||||
|
|
@ -81,11 +83,45 @@ export const useAuthStore = defineStore('auth', {
|
||||||
? JSON.parse(userCookie.value)
|
? JSON.parse(userCookie.value)
|
||||||
: userCookie.value;
|
: userCookie.value;
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Invalid user data
|
// Invalid user data
|
||||||
this.logout();
|
this.logout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Case 2: No token but have refresh token - try to refresh
|
||||||
|
if (!tokenCookie.value && refreshTokenCookie.value && userCookie.value) {
|
||||||
|
// Get cookie refs with options BEFORE await to maintain Nuxt context
|
||||||
|
const tokenCookieWithOptions = useCookie('token', {
|
||||||
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
|
sameSite: 'strict'
|
||||||
|
});
|
||||||
|
const refreshTokenCookieWithOptions = useCookie('refreshToken', {
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
sameSite: 'strict'
|
||||||
|
});
|
||||||
|
const currentRefreshToken = refreshTokenCookie.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Token missing, attempting refresh...');
|
||||||
|
const newTokens = await authService.refreshToken(currentRefreshToken);
|
||||||
|
|
||||||
|
// Update cookies with new tokens
|
||||||
|
tokenCookieWithOptions.value = newTokens.token;
|
||||||
|
refreshTokenCookieWithOptions.value = newTokens.refreshToken;
|
||||||
|
|
||||||
|
this.token = newTokens.token;
|
||||||
|
this.user = typeof userCookie.value === 'string'
|
||||||
|
? JSON.parse(userCookie.value)
|
||||||
|
: userCookie.value;
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
console.log('Token refreshed successfully');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Token refresh failed');
|
||||||
|
this.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { instructorService } from '~/services/instructor.service';
|
||||||
|
|
||||||
interface Course {
|
interface Course {
|
||||||
id: string;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
students: number;
|
students: number;
|
||||||
lessons: number;
|
lessons: number;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
thumbnail: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
|
|
@ -17,27 +19,13 @@ interface DashboardStats {
|
||||||
export const useInstructorStore = defineStore('instructor', {
|
export const useInstructorStore = defineStore('instructor', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
stats: {
|
stats: {
|
||||||
totalCourses: 5,
|
totalCourses: 0,
|
||||||
totalStudents: 125,
|
totalStudents: 0,
|
||||||
completedStudents: 45
|
completedStudents: 0
|
||||||
} as DashboardStats,
|
} as DashboardStats,
|
||||||
|
|
||||||
recentCourses: [
|
recentCourses: [] as Course[],
|
||||||
{
|
loading: false
|
||||||
id: '1',
|
|
||||||
title: 'Python เบื้องต้น',
|
|
||||||
students: 45,
|
|
||||||
lessons: 8,
|
|
||||||
icon: '📘'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: 'JavaScript สำหรับเว็บ',
|
|
||||||
students: 32,
|
|
||||||
lessons: 12,
|
|
||||||
icon: '📗'
|
|
||||||
}
|
|
||||||
] as Course[]
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
|
|
@ -47,14 +35,31 @@ export const useInstructorStore = defineStore('instructor', {
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
async fetchDashboardData() {
|
async fetchDashboardData() {
|
||||||
// TODO: Replace with real API call
|
this.loading = true;
|
||||||
// const { $api } = useNuxtApp();
|
try {
|
||||||
// const data = await $api('/instructor/dashboard');
|
// Fetch real courses from API
|
||||||
// this.stats = data.stats;
|
const courses = await instructorService.getCourses();
|
||||||
// this.recentCourses = data.recentCourses;
|
|
||||||
|
|
||||||
// Using mock data for now
|
// Update stats
|
||||||
console.log('Using mock data for instructor dashboard');
|
this.stats.totalCourses = courses.length;
|
||||||
|
// TODO: Get real student counts from API when available
|
||||||
|
this.stats.totalStudents = 0;
|
||||||
|
this.stats.completedStudents = 0;
|
||||||
|
|
||||||
|
// Map to recent courses format (take first 5)
|
||||||
|
this.recentCourses = courses.slice(0, 3).map((course, index) => ({
|
||||||
|
id: course.id,
|
||||||
|
title: course.title.th,
|
||||||
|
students: 0, // TODO: Get from API
|
||||||
|
lessons: 0, // TODO: Get from course detail API
|
||||||
|
icon: ['📘', '📗', '📙', '📕', '📒'][index % 5],
|
||||||
|
thumbnail: course.thumbnail_url || null
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch dashboard data:', error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
134
frontend_management/utils/authFetch.ts
Normal file
134
frontend_management/utils/authFetch.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { authService } from '~/services/auth.service';
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get token or refresh if needed
|
||||||
|
* Handles concurrent requests by sharing same refresh promise
|
||||||
|
*/
|
||||||
|
export const getValidToken = async (): Promise<string | null> => {
|
||||||
|
const tokenCookie = useCookie('token');
|
||||||
|
const refreshTokenCookie = useCookie('refreshToken');
|
||||||
|
|
||||||
|
// If we have token, return it
|
||||||
|
if (tokenCookie.value) {
|
||||||
|
return tokenCookie.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No token but have refresh token, try to refresh
|
||||||
|
if (refreshTokenCookie.value) {
|
||||||
|
// If already refreshing, wait for that promise
|
||||||
|
if (isRefreshing && refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isRefreshing = true;
|
||||||
|
const refreshToken = refreshTokenCookie.value;
|
||||||
|
refreshPromise = authService.refreshToken(refreshToken).then(res => {
|
||||||
|
// Update cookies
|
||||||
|
useCookie('token').value = res.token;
|
||||||
|
useCookie('refreshToken').value = res.refreshToken;
|
||||||
|
return res.token;
|
||||||
|
});
|
||||||
|
const newToken = await refreshPromise;
|
||||||
|
return newToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh failed');
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle 401 error - try to refresh and return new token
|
||||||
|
* Returns null if refresh fails
|
||||||
|
*/
|
||||||
|
export const handleUnauthorized = async (): Promise<string | null> => {
|
||||||
|
const refreshTokenCookie = useCookie('refreshToken');
|
||||||
|
|
||||||
|
if (!refreshTokenCookie.value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already refreshing, wait for that promise
|
||||||
|
if (isRefreshing && refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isRefreshing = true;
|
||||||
|
const refreshToken = refreshTokenCookie.value;
|
||||||
|
refreshPromise = authService.refreshToken(refreshToken).then(res => {
|
||||||
|
// Update cookies
|
||||||
|
useCookie('token').value = res.token;
|
||||||
|
useCookie('refreshToken').value = res.refreshToken;
|
||||||
|
return res.token;
|
||||||
|
});
|
||||||
|
const newToken = await refreshPromise;
|
||||||
|
console.log('Token refreshed successfully');
|
||||||
|
return newToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh failed, need to login again');
|
||||||
|
// Clear all auth cookies
|
||||||
|
useCookie('token').value = null;
|
||||||
|
useCookie('refreshToken').value = null;
|
||||||
|
useCookie('user').value = null;
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshPromise = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create authenticated fetch with auto token refresh
|
||||||
|
*/
|
||||||
|
export const createAuthFetch = () => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
|
||||||
|
return async <T>(
|
||||||
|
url: string,
|
||||||
|
options: {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
body?: any;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> => {
|
||||||
|
const token = await getValidToken();
|
||||||
|
|
||||||
|
const makeRequest = async (authToken: string | null) => {
|
||||||
|
return await $fetch<T>(url, {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
...(authToken ? { Authorization: `Bearer ${authToken}` } : {})
|
||||||
|
},
|
||||||
|
body: options.body
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await makeRequest(token);
|
||||||
|
} catch (error: any) {
|
||||||
|
// If 401, try to refresh
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const newToken = await handleUnauthorized();
|
||||||
|
if (newToken) {
|
||||||
|
// Retry with new token
|
||||||
|
return await makeRequest(newToken);
|
||||||
|
}
|
||||||
|
// Redirect to login
|
||||||
|
navigateTo('/login');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue