feat: Implement instructor course management, including student progress tracking and course content views, alongside admin user management.
This commit is contained in:
parent
c9381b9385
commit
be5b9756be
11 changed files with 2116 additions and 1386 deletions
|
|
@ -85,69 +85,117 @@
|
|||
<h3 class="text-lg font-semibold mb-4">วิดีโอ</h3>
|
||||
|
||||
<!-- Current Video -->
|
||||
<div v-if="lesson.video_url" class="mb-4">
|
||||
<div v-if="lesson.video_url" class="mb-6">
|
||||
<p class="text-sm text-gray-500 mb-2">วิดีโอปัจจุบัน:</p>
|
||||
<div v-if="isYoutubeUrl(lesson.video_url)" class="aspect-video w-full max-w-xl rounded-lg overflow-hidden bg-black">
|
||||
<iframe
|
||||
:src="getEmbedUrl(lesson.video_url)"
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<video
|
||||
v-else
|
||||
:src="lesson.video_url"
|
||||
controls
|
||||
class="w-full max-w-xl rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<q-tabs
|
||||
v-model="videoSourceType"
|
||||
dense
|
||||
class="text-grey"
|
||||
active-color="primary"
|
||||
indicator-color="primary"
|
||||
align="left"
|
||||
narrow-indicator
|
||||
>
|
||||
<q-tab name="upload" label="อัพโหลดไฟล์" />
|
||||
<q-tab name="youtube" label="YouTube" />
|
||||
</q-tabs>
|
||||
<q-separator />
|
||||
</div>
|
||||
|
||||
<!-- Upload New Video -->
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors"
|
||||
@click="triggerVideoInput"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleVideoDrop"
|
||||
>
|
||||
<input
|
||||
ref="videoInput"
|
||||
type="file"
|
||||
accept="video/mp4,video/webm"
|
||||
class="hidden"
|
||||
@change="handleVideoSelect"
|
||||
/>
|
||||
<div v-if="selectedVideo">
|
||||
<q-icon name="videocam" size="48px" color="primary" />
|
||||
<p class="mt-2 font-medium">{{ selectedVideo.name }}</p>
|
||||
<p class="text-sm text-gray-400">{{ formatFileSize(selectedVideo.size) }}</p>
|
||||
<q-btn
|
||||
class="mt-2"
|
||||
flat
|
||||
size="sm"
|
||||
color="negative"
|
||||
label="ลบ"
|
||||
icon="close"
|
||||
@click.stop="selectedVideo = null"
|
||||
<div v-if="videoSourceType === 'upload'">
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors"
|
||||
@click="triggerVideoInput"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleVideoDrop"
|
||||
>
|
||||
<input
|
||||
ref="videoInput"
|
||||
type="file"
|
||||
accept="video/mp4,video/webm"
|
||||
class="hidden"
|
||||
@change="handleVideoSelect"
|
||||
/>
|
||||
<div v-if="selectedVideo">
|
||||
<q-icon name="videocam" size="48px" color="primary" />
|
||||
<p class="mt-2 font-medium">{{ selectedVideo.name }}</p>
|
||||
<p class="text-sm text-gray-400">{{ formatFileSize(selectedVideo.size) }}</p>
|
||||
<q-btn
|
||||
class="mt-2"
|
||||
flat
|
||||
size="sm"
|
||||
color="negative"
|
||||
label="ลบ"
|
||||
icon="close"
|
||||
@click.stop="selectedVideo = null"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-icon name="cloud_upload" size="48px" color="grey" />
|
||||
<p class="mt-2 text-gray-500">ลากไฟล์มาวางที่นี่ หรือคลิกเพื่อเลือกไฟล์</p>
|
||||
<p class="text-sm text-gray-400">รองรับ MP4, WebM (สูงสุด 500 MB)</p>
|
||||
<q-btn
|
||||
class="mt-4"
|
||||
color="primary"
|
||||
outline
|
||||
label="เลือกไฟล์วิดีโอ"
|
||||
icon="upload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-icon name="cloud_upload" size="48px" color="grey" />
|
||||
<p class="mt-2 text-gray-500">ลากไฟล์มาวางที่นี่ หรือคลิกเพื่อเลือกไฟล์</p>
|
||||
<p class="text-sm text-gray-400">รองรับ MP4, WebM (สูงสุด 500 MB)</p>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<div v-if="selectedVideo" class="mt-4 text-center">
|
||||
<q-btn
|
||||
class="mt-4"
|
||||
color="primary"
|
||||
outline
|
||||
label="เลือกไฟล์วิดีโอ"
|
||||
icon="upload"
|
||||
label="อัพโหลดวิดีโอ"
|
||||
icon="cloud_upload"
|
||||
:loading="uploadingVideo"
|
||||
@click="uploadVideo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<div v-if="selectedVideo" class="mt-4 text-center">
|
||||
|
||||
<!-- YouTube Input -->
|
||||
<div v-else class="max-w-xl">
|
||||
<q-input
|
||||
v-model="youtubeUrl"
|
||||
label="YouTube URL"
|
||||
hint="ตัวอย่าง: https://www.youtube.com/watch?v=..."
|
||||
outlined
|
||||
class="mb-4"
|
||||
/>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="อัพโหลดวิดีโอ"
|
||||
icon="cloud_upload"
|
||||
:loading="uploadingVideo"
|
||||
@click="uploadVideo"
|
||||
label="บันทึก YouTube Video"
|
||||
icon="save"
|
||||
:loading="savingYoutube"
|
||||
@click="saveYoutubeVideo"
|
||||
:disable="!isValidYoutubeUrl"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
</q-card-section>
|
||||
|
||||
<!-- Prerequisite Settings Section -->
|
||||
<q-separator />
|
||||
<q-card-section>
|
||||
|
|
@ -196,7 +244,7 @@
|
|||
<!-- Attachments List -->
|
||||
<div v-if="lesson.attachments && lesson.attachments.length > 0" class="space-y-2 mb-4">
|
||||
<div
|
||||
v-for="attachment in lesson.attachments"
|
||||
v-for="attachment in visibleAttachments"
|
||||
:key="attachment.id"
|
||||
class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
|
|
@ -249,7 +297,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { instructorService, type LessonResponse } from '~/services/instructor.service';
|
||||
|
|
@ -265,6 +313,11 @@ const loading = ref(true);
|
|||
const saving = ref(false);
|
||||
const lesson = ref<LessonResponse | null>(null);
|
||||
|
||||
const visibleAttachments = computed(() => {
|
||||
if (!lesson.value?.attachments) return [];
|
||||
return lesson.value.attachments.filter(a => !a.mime_type.includes('video'));
|
||||
});
|
||||
|
||||
const form = ref({
|
||||
title: { th: '', en: '' },
|
||||
content: { th: '', en: '' }
|
||||
|
|
@ -292,6 +345,103 @@ const selectedVideo = ref<File | null>(null);
|
|||
const uploadingVideo = ref(false);
|
||||
const videoInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// YouTube Support
|
||||
const videoSourceType = ref('upload');
|
||||
const youtubeUrl = ref('');
|
||||
const savingYoutube = ref(false);
|
||||
|
||||
const youtubeRegex = /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
|
||||
const isYoutubeUrl = (url: string) => {
|
||||
return youtubeRegex.test(url);
|
||||
};
|
||||
|
||||
const getEmbedUrl = (url: string) => {
|
||||
const match = url.match(youtubeRegex);
|
||||
if (match && match[1]) {
|
||||
return `https://www.youtube.com/embed/${match[1]}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const fetchYoutubeTitle = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(`https://noembed.com/embed?url=${url}`);
|
||||
const data = await response.json();
|
||||
if (data.title) {
|
||||
return data.title;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch YouTube title:', error);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const isValidYoutubeUrl = computed(() => {
|
||||
return youtubeRegex.test(youtubeUrl.value);
|
||||
});
|
||||
|
||||
// Watch for YouTube URL changes to fetch title
|
||||
watch(youtubeUrl, async (newUrl) => {
|
||||
if (isValidYoutubeUrl.value) {
|
||||
const title = await fetchYoutubeTitle(newUrl);
|
||||
if (title && !form.value.title.th) {
|
||||
// Autocomplete lesson title if empty
|
||||
form.value.title.th = title;
|
||||
}
|
||||
// We could store it to use as video_title specifically,
|
||||
// but for now let's rely on form.title.th or the fetched title for the API call validation if we want to be strict.
|
||||
// However, the user probably wants the 'video_title' passed to backend to be correct.
|
||||
// Let's store it.
|
||||
fetchedVideoTitle.value = title;
|
||||
} else {
|
||||
fetchedVideoTitle.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
const fetchedVideoTitle = ref('');
|
||||
|
||||
const saveYoutubeVideo = async () => {
|
||||
if (!isValidYoutubeUrl.value) return;
|
||||
|
||||
const match = youtubeUrl.value.match(youtubeRegex);
|
||||
if (!match || !match[1]) return;
|
||||
|
||||
const videoId = match[1];
|
||||
savingYoutube.value = true;
|
||||
|
||||
try {
|
||||
// If we have a fetched title, we can genericallly use it, or fallback to lesson title
|
||||
const videoTitle = fetchedVideoTitle.value || form.value.title.th || 'Untitled Video';
|
||||
|
||||
const response = await instructorService.setLessonYoutubeVideo(
|
||||
courseId,
|
||||
chapterId,
|
||||
lessonId,
|
||||
videoId,
|
||||
videoTitle
|
||||
);
|
||||
|
||||
// If lesson title is empty, update it too
|
||||
if (!form.value.title.th && fetchedVideoTitle.value) {
|
||||
form.value.title.th = fetchedVideoTitle.value;
|
||||
// We might want to save the lesson title change as well?
|
||||
// The current flow saves video separately.
|
||||
// Let's just update the local form for now, user can click "Save" on the form if they want to persist the name change to the lesson itself.
|
||||
}
|
||||
|
||||
$q.notify({ type: 'positive', message: response.message || 'บันทึกวิดีโอ YouTube สำเร็จ', position: 'top' });
|
||||
youtubeUrl.value = '';
|
||||
fetchedVideoTitle.value = '';
|
||||
await fetchLesson();
|
||||
} catch (error) {
|
||||
console.error('Failed to save YouTube video:', error);
|
||||
$q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถบันทึกวิดีโอได้', position: 'top' });
|
||||
} finally {
|
||||
savingYoutube.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const triggerVideoInput = () => {
|
||||
videoInput.value?.click();
|
||||
};
|
||||
|
|
@ -306,6 +456,14 @@ const fetchLesson = async () => {
|
|||
content: data.content ? { ...data.content } : { th: '', en: '' }
|
||||
};
|
||||
|
||||
// Check if current video is YouTube
|
||||
if (data.video_url && isYoutubeUrl(data.video_url)) {
|
||||
videoSourceType.value = 'youtube';
|
||||
youtubeUrl.value = data.video_url;
|
||||
} else {
|
||||
videoSourceType.value = 'upload';
|
||||
}
|
||||
|
||||
// Load prerequisite settings
|
||||
prerequisiteSettings.value = {
|
||||
prerequisite_lesson_ids: data.prerequisite_lesson_ids || []
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -42,6 +42,7 @@
|
|||
placeholder="ค้นหาหลักสูตร..."
|
||||
outlined
|
||||
dense
|
||||
debounce="600"
|
||||
bg-color="grey-1"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue