feat: Add initial frontend setup including authentication, instructor, and admin course management modules.
This commit is contained in:
parent
9fd217e1db
commit
19844f343b
16 changed files with 2065 additions and 293 deletions
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue