feat: Implement authentication system with token refresh and initial instructor dashboard with course management.

This commit is contained in:
Missez 2026-01-23 09:53:39 +07:00
parent 0eb9b522f6
commit ab3124628c
11 changed files with 1053 additions and 93 deletions

View file

@ -103,7 +103,7 @@
<div v-else class="space-y-4">
<q-card
v-for="chapter in course.chapters"
v-for="chapter in sortedChapters"
:key="chapter.id"
flat
bordered
@ -125,7 +125,7 @@
<!-- Lessons -->
<q-list separator class="border-t">
<q-item
v-for="lesson in chapter.lessons"
v-for="lesson in getSortedLessons(chapter)"
:key="lesson.id"
class="py-3"
>
@ -204,6 +204,11 @@ const totalLessons = computed(() => {
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
const fetchCourse = async () => {
loading.value = true;
@ -246,6 +251,10 @@ const getChapterDuration = (chapter: ChapterResponse) => {
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 icons: Record<string, string> = {
VIDEO: 'play_circle',

View 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>

View file

@ -112,8 +112,14 @@
>
<q-card-section>
<div class="flex gap-4">
<div class="w-20 h-16 bg-primary-100 rounded-lg flex items-center justify-center text-3xl">
{{ course.icon }}
<div class="w-20 h-16 bg-primary-100 rounded-lg flex items-center justify-center overflow-hidden flex-shrink-0">
<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 class="flex-1">
<div class="font-semibold text-gray-900">{{ course.title }}</div>

View file

@ -124,6 +124,18 @@ const $q = useQuasar();
const authStore = useAuthStore();
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
const email = ref('');
const password = ref('');