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
|
|
@ -116,6 +116,15 @@
|
|||
label="แสดงเฉลยหลังทำเสร็จ"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 text-right">
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="บันทึกการตั้งค่า"
|
||||
icon="save"
|
||||
:loading="savingSettings"
|
||||
@click="saveQuizSettings"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
|
|
@ -133,44 +142,56 @@
|
|||
</div>
|
||||
|
||||
<!-- Questions List -->
|
||||
<div v-if="questions.length > 0" class="space-y-4">
|
||||
<q-card
|
||||
v-for="(question, qIndex) in questions"
|
||||
:key="qIndex"
|
||||
flat
|
||||
bordered
|
||||
class="bg-gray-50"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<span class="font-medium">คำถามที่ {{ qIndex + 1 }}</span>
|
||||
<div class="flex gap-1">
|
||||
<q-btn flat round dense icon="edit" size="sm" @click="editQuestion(qIndex)" />
|
||||
<q-btn flat round dense icon="delete" color="negative" size="sm" @click="removeQuestion(qIndex)" />
|
||||
<draggable
|
||||
v-if="questions.length > 0"
|
||||
v-model="questions"
|
||||
item-key="id"
|
||||
handle=".drag-handle"
|
||||
animation="200"
|
||||
ghost-class="opacity-50"
|
||||
class="space-y-4"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element: question, index: qIndex }">
|
||||
<q-card flat bordered class="bg-gray-50">
|
||||
<q-card-section>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<q-icon
|
||||
name="drag_indicator"
|
||||
class="drag-handle cursor-grab text-gray-400 hover:text-gray-600"
|
||||
size="20px"
|
||||
/>
|
||||
<span class="font-medium">คำถามที่ {{ qIndex + 1 }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<q-btn flat round dense icon="edit" size="sm" @click="editQuestion(qIndex)" />
|
||||
<q-btn flat round dense icon="delete" color="negative" size="sm" @click="removeQuestion(qIndex)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-2">{{ question.text.th || '(ยังไม่ได้กรอกคำถาม)' }}</p>
|
||||
|
||||
<!-- Choices Preview -->
|
||||
<div class="pl-4 space-y-1">
|
||||
<div
|
||||
v-for="(choice, cIndex) in question.choices"
|
||||
:key="cIndex"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<q-icon
|
||||
:name="choice.is_correct ? 'check_circle' : 'radio_button_unchecked'"
|
||||
:color="choice.is_correct ? 'positive' : 'grey'"
|
||||
size="18px"
|
||||
/>
|
||||
<span :class="{ 'text-green-600 font-medium': choice.is_correct }">
|
||||
{{ choice.text.th || `ตัวเลือก ${cIndex + 1}` }}
|
||||
</span>
|
||||
<p class="mb-2">{{ question.text.th || '(ยังไม่ได้กรอกคำถาม)' }}</p>
|
||||
|
||||
<!-- Choices Preview -->
|
||||
<div class="pl-4 space-y-1">
|
||||
<div
|
||||
v-for="(choice, cIndex) in question.choices"
|
||||
:key="cIndex"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<q-icon
|
||||
:name="choice.is_correct ? 'check_circle' : 'radio_button_unchecked'"
|
||||
:color="choice.is_correct ? 'positive' : 'grey'"
|
||||
size="18px"
|
||||
/>
|
||||
<span :class="{ 'text-green-600 font-medium': choice.is_correct }">
|
||||
{{ choice.text.th || `ตัวเลือก ${cIndex + 1}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
|
|
@ -264,6 +285,7 @@
|
|||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useQuasar } from 'quasar';
|
||||
import draggable from 'vuedraggable';
|
||||
import { instructorService, type LessonResponse, type CreateQuestionRequest } from '~/services/instructor.service';
|
||||
|
||||
const route = useRoute();
|
||||
|
|
@ -290,6 +312,50 @@ const quizSettings = ref({
|
|||
show_answers_after_completion: true
|
||||
});
|
||||
|
||||
const savingSettings = ref(false);
|
||||
|
||||
const saveQuizSettings = async () => {
|
||||
savingSettings.value = true;
|
||||
try {
|
||||
await instructorService.updateQuizSettings(courseId, chapterId, lessonId, {
|
||||
title: form.value.title,
|
||||
description: form.value.content,
|
||||
passing_score: quizSettings.value.passing_score,
|
||||
time_limit: quizSettings.value.time_limit,
|
||||
shuffle_questions: quizSettings.value.shuffle_questions,
|
||||
shuffle_choices: quizSettings.value.shuffle_choices,
|
||||
show_answers_after_completion: quizSettings.value.show_answers_after_completion
|
||||
});
|
||||
$q.notify({ type: 'positive', message: 'บันทึกการตั้งค่าสำเร็จ', position: 'top' });
|
||||
} catch (error) {
|
||||
console.error('Failed to save quiz settings:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถบันทึกการตั้งค่าได้', position: 'top' });
|
||||
} finally {
|
||||
savingSettings.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Drag and Drop handler (vuedraggable)
|
||||
const onDragEnd = async (event: { oldIndex: number; newIndex: number }) => {
|
||||
const { oldIndex, newIndex } = event;
|
||||
if (oldIndex === newIndex) return;
|
||||
|
||||
const question = questions.value[newIndex];
|
||||
if (!question.id) return;
|
||||
|
||||
try {
|
||||
// Call API with new position (1-indexed)
|
||||
await instructorService.reorderQuestion(courseId, chapterId, lessonId, question.id, newIndex + 1);
|
||||
$q.notify({ type: 'positive', message: 'เรียงลำดับคำถามสำเร็จ', position: 'top' });
|
||||
} catch (error) {
|
||||
console.error('Failed to reorder question:', error);
|
||||
// Revert on error - swap back
|
||||
const [item] = questions.value.splice(newIndex, 1);
|
||||
questions.value.splice(oldIndex, 0, item);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||
}
|
||||
};
|
||||
|
||||
interface QuizChoice {
|
||||
id?: number;
|
||||
text: { th: string; en: string };
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@
|
|||
</div>
|
||||
|
||||
<input
|
||||
ref="attachmentInput"
|
||||
:ref="(el: any) => attachmentInput = el"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
|
|
@ -326,6 +326,7 @@ const uploadingAttachment = ref(false);
|
|||
const deletingAttachmentId = ref<number | null>(null);
|
||||
|
||||
const triggerAttachmentInput = () => {
|
||||
console.log('Trigger attachment input:', attachmentInput.value);
|
||||
attachmentInput.value?.click();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -167,12 +167,205 @@
|
|||
|
||||
<!-- Announcements Tab -->
|
||||
<q-tab-panel name="announcements" class="p-6">
|
||||
<div class="text-center py-10 text-gray-500">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">ประกาศ</h2>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="+ สร้างประกาศ"
|
||||
icon="add"
|
||||
@click="openAnnouncementDialog()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingAnnouncements" class="flex justify-center py-10">
|
||||
<q-spinner color="primary" size="40px" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="announcements.length === 0" class="text-center py-10 text-gray-500">
|
||||
<q-icon name="campaign" size="60px" color="grey-4" class="mb-4" />
|
||||
<p>ยังไม่มีประกาศ</p>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="สร้างประกาศแรก"
|
||||
class="mt-4"
|
||||
@click="openAnnouncementDialog()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<q-card
|
||||
v-for="announcement in announcements"
|
||||
:key="announcement.id"
|
||||
flat
|
||||
bordered
|
||||
class="rounded-lg"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<q-icon v-if="announcement.is_pinned" name="push_pin" color="orange" size="20px" />
|
||||
<h3 class="font-semibold text-gray-900">{{ announcement.title.th }}</h3>
|
||||
<q-badge :color="announcement.status === 'PUBLISHED' ? 'green' : 'grey'">
|
||||
{{ announcement.status === 'PUBLISHED' ? 'เผยแพร่' : 'ฉบับร่าง' }}
|
||||
</q-badge>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ announcement.title.en }}</p>
|
||||
<p class="text-gray-600 mt-3 whitespace-pre-line">{{ announcement.content.th }}</p>
|
||||
<p class="text-xs text-gray-400 mt-3">
|
||||
สร้างเมื่อ {{ formatDate(announcement.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<q-btn flat round icon="edit" color="primary" size="sm" @click="openAnnouncementDialog(announcement)" />
|
||||
<q-btn flat round icon="delete" color="negative" size="sm" @click="confirmDeleteAnnouncement(announcement)" />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
|
||||
<!-- Announcement Dialog -->
|
||||
<q-dialog v-model="showAnnouncementDialog" persistent>
|
||||
<q-card style="min-width: 600px; max-width: 700px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">{{ editingAnnouncement ? 'แก้ไขประกาศ' : 'สร้างประกาศใหม่' }}</div>
|
||||
<q-space />
|
||||
<q-btn icon="close" flat round dense @click="showAnnouncementDialog = false" />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<div class="space-y-4">
|
||||
<q-input
|
||||
v-model="announcementForm.title.th"
|
||||
outlined
|
||||
label="หัวข้อ (ภาษาไทย) *"
|
||||
:rules="[val => !!val || 'กรุณากรอกหัวข้อ']"
|
||||
/>
|
||||
<q-input
|
||||
v-model="announcementForm.title.en"
|
||||
outlined
|
||||
label="หัวข้อ (English)"
|
||||
/>
|
||||
<q-input
|
||||
v-model="announcementForm.content.th"
|
||||
outlined
|
||||
type="textarea"
|
||||
rows="4"
|
||||
label="เนื้อหา (ภาษาไทย) *"
|
||||
:rules="[val => !!val || 'กรุณากรอกเนื้อหา']"
|
||||
/>
|
||||
<q-input
|
||||
v-model="announcementForm.content.en"
|
||||
outlined
|
||||
type="textarea"
|
||||
rows="4"
|
||||
label="เนื้อหา (English)"
|
||||
/>
|
||||
<div class="flex gap-4">
|
||||
<q-toggle v-model="announcementForm.is_pinned" label="ปักหมุด" />
|
||||
<q-select
|
||||
v-model="announcementForm.status"
|
||||
:options="[
|
||||
{ label: 'ฉบับร่าง', value: 'DRAFT' },
|
||||
{ label: 'เผยแพร่', value: 'PUBLISHED' }
|
||||
]"
|
||||
outlined
|
||||
emit-value
|
||||
map-options
|
||||
label="สถานะ"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Attachments Section -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h4 class="font-semibold text-gray-700">ไฟล์แนบ</h4>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="primary"
|
||||
icon="attach_file"
|
||||
label="เพิ่มไฟล์"
|
||||
:loading="uploadingAttachment"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Existing Attachments (edit mode) -->
|
||||
<div v-if="editingAnnouncement?.attachments?.length > 0" class="space-y-2 mb-2">
|
||||
<div
|
||||
v-for="attachment in editingAnnouncement.attachments"
|
||||
:key="attachment.id"
|
||||
class="flex items-center justify-between bg-gray-50 rounded p-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<q-icon name="insert_drive_file" color="grey" />
|
||||
<span class="text-sm">{{ attachment.file_name }}</span>
|
||||
<span class="text-xs text-gray-400">({{ formatFileSize(attachment.file_size) }})</span>
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
size="sm"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
:loading="deletingAttachmentId === attachment.id"
|
||||
@click="deleteAttachment(attachment.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Files (create mode) -->
|
||||
<div v-if="pendingFiles.length > 0" class="space-y-2 mb-2">
|
||||
<div
|
||||
v-for="(file, index) in pendingFiles"
|
||||
:key="index"
|
||||
class="flex items-center justify-between bg-blue-50 rounded p-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<q-icon name="upload_file" color="primary" />
|
||||
<span class="text-sm">{{ file.name }}</span>
|
||||
<span class="text-xs text-gray-400">({{ formatFileSize(file.size) }})</span>
|
||||
<q-badge color="blue" label="รอสร้าง" size="sm" />
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
size="sm"
|
||||
icon="close"
|
||||
color="grey"
|
||||
@click="removePendingFile(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!editingAnnouncement?.attachments?.length && pendingFiles.length === 0" class="text-center py-4 text-gray-400 text-sm">
|
||||
ยังไม่มีไฟล์แนบ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="q-pa-md">
|
||||
<q-btn flat label="ยกเลิก" color="grey-7" @click="showAnnouncementDialog = false" />
|
||||
<q-btn
|
||||
:label="editingAnnouncement ? 'บันทึก' : 'สร้าง'"
|
||||
color="primary"
|
||||
:loading="savingAnnouncement"
|
||||
@click="saveAnnouncement"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -182,7 +375,9 @@ import { useQuasar } from 'quasar';
|
|||
import {
|
||||
instructorService,
|
||||
type CourseDetailResponse,
|
||||
type ChapterResponse
|
||||
type ChapterResponse,
|
||||
type AnnouncementResponse,
|
||||
type CreateAnnouncementRequest
|
||||
} from '~/services/instructor.service';
|
||||
|
||||
definePageMeta({
|
||||
|
|
@ -198,6 +393,25 @@ const course = ref<CourseDetailResponse | null>(null);
|
|||
const loading = ref(true);
|
||||
const activeTab = ref('structure');
|
||||
|
||||
// Announcements data
|
||||
const announcements = ref<AnnouncementResponse[]>([]);
|
||||
const loadingAnnouncements = ref(false);
|
||||
const showAnnouncementDialog = ref(false);
|
||||
const editingAnnouncement = ref<AnnouncementResponse | null>(null);
|
||||
const savingAnnouncement = ref(false);
|
||||
const announcementForm = ref<CreateAnnouncementRequest>({
|
||||
title: { th: '', en: '' },
|
||||
content: { th: '', en: '' },
|
||||
status: 'DRAFT',
|
||||
is_pinned: false
|
||||
});
|
||||
|
||||
// Attachment handling
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const uploadingAttachment = ref(false);
|
||||
const deletingAttachmentId = ref<number | null>(null);
|
||||
const pendingFiles = ref<File[]>([]);
|
||||
|
||||
// Computed
|
||||
const totalLessons = computed(() => {
|
||||
if (!course.value) return 0;
|
||||
|
|
@ -300,6 +514,224 @@ const requestApproval = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// Announcements methods
|
||||
const fetchAnnouncements = async () => {
|
||||
loadingAnnouncements.value = true;
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
announcements.value = await instructorService.getAnnouncements(courseId);
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'ไม่สามารถโหลดข้อมูลประกาศได้',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
loadingAnnouncements.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('th-TH', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const openAnnouncementDialog = (announcement?: AnnouncementResponse) => {
|
||||
if (announcement) {
|
||||
editingAnnouncement.value = announcement;
|
||||
announcementForm.value = {
|
||||
title: { ...announcement.title },
|
||||
content: { ...announcement.content },
|
||||
status: announcement.status,
|
||||
is_pinned: announcement.is_pinned
|
||||
};
|
||||
} else {
|
||||
editingAnnouncement.value = null;
|
||||
announcementForm.value = {
|
||||
title: { th: '', en: '' },
|
||||
content: { th: '', en: '' },
|
||||
status: 'DRAFT',
|
||||
is_pinned: false
|
||||
};
|
||||
}
|
||||
pendingFiles.value = [];
|
||||
showAnnouncementDialog.value = true;
|
||||
};
|
||||
|
||||
const saveAnnouncement = async () => {
|
||||
if (!announcementForm.value.title.th || !announcementForm.value.content.th) {
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: 'กรุณากรอกหัวข้อและเนื้อหา',
|
||||
position: 'top'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
savingAnnouncement.value = true;
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
if (editingAnnouncement.value) {
|
||||
await instructorService.updateAnnouncement(courseId, editingAnnouncement.value.id, announcementForm.value);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'บันทึกประกาศสำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
} else {
|
||||
// Create announcement with files
|
||||
await instructorService.createAnnouncement(
|
||||
courseId,
|
||||
announcementForm.value,
|
||||
pendingFiles.value.length > 0 ? pendingFiles.value : undefined
|
||||
);
|
||||
pendingFiles.value = [];
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'สร้างประกาศสำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
showAnnouncementDialog.value = false;
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาด',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
savingAnnouncement.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteAnnouncement = (announcement: AnnouncementResponse) => {
|
||||
$q.dialog({
|
||||
title: 'ยืนยันการลบ',
|
||||
message: `คุณต้องการลบประกาศ "${announcement.title.th}" หรือไม่?`,
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
await instructorService.deleteAnnouncement(courseId, announcement.id);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'ลบประกาศสำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการลบประกาศ',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Attachment handling methods
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click();
|
||||
};
|
||||
|
||||
const handleFileUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// If editing existing announcement, upload immediately
|
||||
if (editingAnnouncement.value) {
|
||||
uploadingAttachment.value = true;
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
const updated = await instructorService.uploadAnnouncementAttachment(
|
||||
courseId,
|
||||
editingAnnouncement.value.id,
|
||||
file
|
||||
);
|
||||
editingAnnouncement.value = updated;
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'อัพโหลดไฟล์สำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการอัพโหลดไฟล์',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
uploadingAttachment.value = false;
|
||||
input.value = '';
|
||||
}
|
||||
} else {
|
||||
// If creating new announcement, add to pending files
|
||||
pendingFiles.value.push(file);
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const removePendingFile = (index: number) => {
|
||||
pendingFiles.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const deleteAttachment = async (attachmentId: number) => {
|
||||
if (!editingAnnouncement.value) return;
|
||||
|
||||
deletingAttachmentId.value = attachmentId;
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
await instructorService.deleteAnnouncementAttachment(
|
||||
courseId,
|
||||
editingAnnouncement.value.id,
|
||||
attachmentId
|
||||
);
|
||||
// Remove from local state
|
||||
editingAnnouncement.value.attachments = editingAnnouncement.value.attachments.filter(
|
||||
a => a.id !== attachmentId
|
||||
);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'ลบไฟล์สำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการลบไฟล์',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
deletingAttachmentId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Watch for tab change to load announcements
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab === 'announcements' && announcements.value.length === 0) {
|
||||
fetchAnnouncements();
|
||||
}
|
||||
});
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchCourse();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -11,15 +11,32 @@
|
|||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<!-- Avatar Section -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-32 h-32 bg-primary-100 rounded-full flex items-center justify-center text-6xl mb-4">
|
||||
{{ profile.avatar }}
|
||||
<div class="w-32 h-32 rounded-full flex items-center justify-center text-6xl mb-4 overflow-hidden bg-primary-100">
|
||||
<img
|
||||
v-if="profile.avatarUrl"
|
||||
:key="profile.avatarUrl"
|
||||
:src="profile.avatarUrl"
|
||||
alt=""
|
||||
class="w-full h-full object-cover"
|
||||
@error="onAvatarError"
|
||||
/>
|
||||
<span v-else>{{ profile.avatar }}</span>
|
||||
</div>
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
label="เปลี่ยนรูป"
|
||||
icon="photo_camera"
|
||||
@click="handleAvatarUpload"
|
||||
:loading="uploadingAvatar"
|
||||
@click="triggerAvatarUpload"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-2">ขนาดไม่เกิน 5MB</p>
|
||||
<input
|
||||
ref="avatarInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleAvatarUpload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -283,6 +300,7 @@ const profile = ref({
|
|||
role: '',
|
||||
roleName: '',
|
||||
avatar: '👨🏫',
|
||||
avatarUrl: '' as string | null,
|
||||
createdAt: ''
|
||||
});
|
||||
|
||||
|
|
@ -330,12 +348,67 @@ const formatDate = (date: string) => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleAvatarUpload = () => {
|
||||
$q.notify({
|
||||
type: 'info',
|
||||
message: 'ฟีเจอร์อัพโหลดรูปภาพจะพร้อมใช้งานเร็วๆ นี้',
|
||||
position: 'top'
|
||||
});
|
||||
// Avatar upload
|
||||
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
||||
const uploadingAvatar = ref(false);
|
||||
|
||||
const triggerAvatarUpload = () => {
|
||||
avatarInputRef.value?.click();
|
||||
};
|
||||
|
||||
const onAvatarError = () => {
|
||||
// Fallback to emoji if image fails
|
||||
profile.value.avatarUrl = null;
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: 'กรุณาเลือกไฟล์รูปภาพเท่านั้น',
|
||||
position: 'top'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: 'ไฟล์มีขนาดใหญ่เกิน 5MB',
|
||||
position: 'top'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uploadingAvatar.value = true;
|
||||
try {
|
||||
await userService.uploadAvatar(file);
|
||||
|
||||
// Re-fetch profile to get presigned URL from backend
|
||||
await fetchProfile();
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'อัพโหลดรูปโปรไฟล์สำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to upload avatar:', error);
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการอัพโหลดรูป',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
uploadingAvatar.value = false;
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
|
|
@ -432,7 +505,8 @@ const fetchProfile = async () => {
|
|||
phone: data.profile.phone || '',
|
||||
role: data.role.code,
|
||||
roleName: data.role.name.th,
|
||||
avatar: data.profile.avatar_url || '👨🏫',
|
||||
avatar: '👨🏫',
|
||||
avatarUrl: data.profile.avatar_url,
|
||||
createdAt: data.created_at
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue