feat: Add initial frontend setup including authentication, instructor, and admin course management modules.

This commit is contained in:
Missez 2026-01-28 13:38:54 +07:00
parent 9fd217e1db
commit 19844f343b
16 changed files with 2065 additions and 293 deletions

View file

@ -38,128 +38,101 @@
</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 }} บทเรยน
<draggable
v-else
v-model="chapters"
item-key="id"
handle=".chapter-handle"
animation="200"
ghost-class="opacity-50"
class="space-y-4"
@end="onChapterDragEnd"
>
<template #item="{ element: chapter, index: chapterIndex }">
<q-card flat bordered class="rounded-lg">
<!-- Chapter Header -->
<q-card-section class="bg-gray-50">
<div class="flex items-center gap-3">
<q-icon name="drag_indicator" class="chapter-handle cursor-move text-gray-400 hover:text-gray-600" />
<div class="flex-1">
<div class="font-semibold text-gray-900">
บทท {{ chapterIndex + 1 }}: {{ 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-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>
</q-card-section>
<!-- Lessons List -->
<q-slide-transition>
<div v-show="chapter.expanded !== false">
<q-list separator>
<q-item
v-for="(lesson, lessonIndex) in getSortedLessons(chapter.lessons)"
:key="lesson.id"
class="py-3"
:class="{ 'opacity-50': draggedLesson?.id === lesson.id, 'bg-primary-50': dragOverLesson?.id === lesson.id }"
draggable="true"
@dragstart="onLessonDragStart(chapter, lesson, $event)"
@dragend="onLessonDragEnd"
@dragover.prevent="onLessonDragOver(chapter, lesson)"
@dragleave="onLessonDragLeave"
@drop.prevent="onLessonDrop(chapter, lesson)"
<!-- Lessons List -->
<q-slide-transition>
<div v-show="chapter.expanded !== false">
<draggable
v-model="chapter.lessons"
item-key="id"
handle=".lesson-handle"
animation="200"
ghost-class="opacity-50"
@end="(event: any) => onLessonDragEnd(chapter, event)"
>
<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>
{{ lesson.sort_order }}. {{ lesson.title.th }}
</q-item-label>
<q-item-label caption>
{{ getLessonTypeLabel(lesson.type) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="flex gap-1">
<q-btn flat dense icon="edit" size="sm" @click="navigateToLessonEdit(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>
<template #item="{ element: lesson, index: lessonIndex }">
<q-item class="py-3 border-b">
<q-item-section avatar>
<q-icon name="drag_indicator" class="lesson-handle cursor-move text-gray-300 hover:text-gray-500" />
</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>
{{ lessonIndex + 1 }}. {{ lesson.title.th }}
</q-item-label>
<q-item-label caption>
{{ getLessonTypeLabel(lesson.type) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="flex gap-1">
<q-btn flat dense icon="edit" size="sm" @click="navigateToLessonEdit(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>
</template>
</draggable>
<!-- Empty Lessons -->
<div v-if="chapter.lessons.length === 0" class="p-4 text-center text-gray-400">
งไมบทเรยนในบทน
<!-- Empty Lessons -->
<div v-if="chapter.lessons.length === 0" class="p-4 text-center text-gray-400">
งไมบทเรยนในบทน
</div>
</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>
</q-slide-transition>
</q-card>
</template>
</draggable>
<!-- Chapter Dialog -->
<q-dialog v-model="chapterDialog" persistent>
@ -287,6 +260,7 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import draggable from 'vuedraggable';
import { instructorService, type ChapterResponse, type LessonResponse } from '~/services/instructor.service';
definePageMeta({
@ -315,140 +289,42 @@ 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;
}
// Drag and Drop handlers (vuedraggable)
const onChapterDragEnd = async (event: { oldIndex: number; newIndex: number }) => {
const { oldIndex, newIndex } = event;
if (oldIndex === newIndex) return;
const chapter = chapters.value[newIndex];
if (!chapter) 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();
await instructorService.reorderChapter(courseId.value, chapter.id, newIndex + 1);
$q.notify({ type: 'positive', message: 'เรียงลำดับบทสำเร็จ', position: 'top' });
} catch (error) {
// Revert via re-fetch to guarantee consistency
fetchChapters();
console.error('Failed to reorder chapter:', error);
// Revert
const [item] = chapters.value.splice(newIndex, 1);
chapters.value.splice(oldIndex, 0, item);
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
} finally {
onDragEnd();
}
};
// Lesson Drag and Drop
const draggedLesson = ref<LessonResponse | null>(null);
const dragOverLesson = ref<LessonResponse | null>(null);
const draggedLessonChapter = ref<ChapterResponse | null>(null);
const onLessonDragStart = (chapter: ChapterResponse, lesson: LessonResponse, event: DragEvent) => {
draggedLesson.value = lesson;
draggedLessonChapter.value = chapter;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
};
const onLessonDragEnd = () => {
draggedLesson.value = null;
dragOverLesson.value = null;
draggedLessonChapter.value = null;
};
const onLessonDragOver = (chapter: ChapterResponse, lesson: LessonResponse) => {
// Only allow drag within same chapter
if (draggedLesson.value && draggedLessonChapter.value?.id === chapter.id && draggedLesson.value.id !== lesson.id) {
dragOverLesson.value = lesson;
}
};
const onLessonDragLeave = () => {
dragOverLesson.value = null;
};
const onLessonDrop = async (chapter: ChapterResponse, targetLesson: LessonResponse) => {
if (!draggedLesson.value || !draggedLessonChapter.value || draggedLessonChapter.value.id !== chapter.id || draggedLesson.value.id === targetLesson.id) {
onLessonDragEnd();
return;
}
const onLessonDragEnd = async (chapter: ChapterResponse & { expanded?: boolean }, event: { oldIndex: number; newIndex: number }) => {
const { oldIndex, newIndex } = event;
if (oldIndex === newIndex) return;
const lesson = chapter.lessons[newIndex];
if (!lesson) return;
try {
// Insert at target position - backend will shift other lessons
const targetSortOrder = targetLesson.sort_order;
await instructorService.reorderLesson(courseId.value, chapter.id, draggedLesson.value.id, targetSortOrder);
await instructorService.reorderLesson(courseId.value, chapter.id, lesson.id, newIndex + 1);
$q.notify({ type: 'positive', message: 'เรียงลำดับบทเรียนสำเร็จ', position: 'top' });
fetchChapters();
} catch (error) {
console.error('Failed to reorder lesson:', error);
// Revert
const [item] = chapter.lessons.splice(newIndex, 1);
chapter.lessons.splice(oldIndex, 0, item);
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
} finally {
onLessonDragEnd();
}
};
@ -474,7 +350,15 @@ const fetchChapters = async () => {
loading.value = true;
try {
const data = await instructorService.getChapters(courseId.value);
chapters.value = data.map(ch => ({ ...ch, expanded: true }));
// Sort chapters by sort_order, and lessons within each chapter by sort_order
chapters.value = data
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map(ch => ({
...ch,
expanded: true,
lessons: ch.lessons.slice().sort((a, b) => a.sort_order - b.sort_order)
}));
} catch (error) {
$q.notify({
type: 'negative',