feat(Editor): Nouveau composant QuestionEditorDialog
- Dialogue modal pour l'ajout/édition de questions - Support complet de l'upload et preview des médias (Image/Vidéo/Audio) - Gestion des paramètres de question (points, autoplay, loop...) - Gestion des notes pour le maître du jeu
This commit is contained in:
352
VApp/src/components/QuestionEditorDialog.vue
Normal file
352
VApp/src/components/QuestionEditorDialog.vue
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog v-model="visible" persistent max-width="800px">
|
||||||
|
<v-card rounded="xl" class="pa-3">
|
||||||
|
<v-card-title class="text-title-style">
|
||||||
|
{{ isNew ? 'Nouvelle question' : 'Éditer la question' }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-container>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-text-field color="primary" density="compact" variant="outlined" rounded="xl" v-model="editedItem.QuestionId" label="ID Question" hint="Unique ID, ex: Q-005"></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-select color="primary" density="compact" variant="outlined" rounded="xl" v-model="editedItem.Type" :items="['video', 'audio', 'picture']" label="Type"></v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-text-field color="primary" density="compact" variant="outlined" rounded="xl" v-model.number="editedItem.Points" type="number" label="Points"></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-textarea color="primary" variant="outlined" rounded="xl" v-model="editedItem.QuestionText" label="Texte de la question" rows="2"></v-textarea>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<!-- Media Section -->
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<!-- Preview Section -->
|
||||||
|
<div v-if="editedItem.MediaUrl" class="preview-box mr-4 text-center">
|
||||||
|
<img v-if="editedItem.Type === 'picture'" :src="getPreviewUrl(editedItem.MediaUrl)" class="preview-content" @click="showFullPreview = true">
|
||||||
|
<video
|
||||||
|
v-else-if="editedItem.Type === 'video'"
|
||||||
|
:src="getPreviewUrl(editedItem.MediaUrl)"
|
||||||
|
class="preview-content"
|
||||||
|
@click="showVideoPreview = true"
|
||||||
|
@loadedmetadata="(e) => e.target.currentTime = e.target.duration / 3"
|
||||||
|
muted
|
||||||
|
></video>
|
||||||
|
<div v-else-if="editedItem.Type === 'audio'" class="text-caption" style="cursor: pointer;" @click="showAudioPreview = true">
|
||||||
|
<v-icon size="64" color="primary">mdi-music</v-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-text-field color="primary" density="compact" variant="outlined" rounded="xl" v-model="editedItem.MediaUrl" label="URL du Média" class="flex-grow-2 mr-2" hide-details></v-text-field>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="fileInputRef"
|
||||||
|
color="primary"
|
||||||
|
style="display: none;"
|
||||||
|
@change="onFileSelected"
|
||||||
|
accept="image/*,video/*,audio/*"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
rounded="xl"
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
:loading="uploading"
|
||||||
|
:disabled="!editedItem.Type"
|
||||||
|
@click="$refs.fileInputRef.click()"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-upload</v-icon>
|
||||||
|
Upload
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="editedItem.MediaUrl"
|
||||||
|
rounded="xl"
|
||||||
|
text="Supprimer"
|
||||||
|
variant="tonal"
|
||||||
|
color="red"
|
||||||
|
class="ml-2"
|
||||||
|
@click="deleteMedia"
|
||||||
|
title="Supprimer le média"
|
||||||
|
></v-btn>
|
||||||
|
</div>
|
||||||
|
<div v-if="uploadError" class="text-caption text-red">{{ uploadError }}</div>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12"><v-divider></v-divider></v-col>
|
||||||
|
<v-col cols="12" class="text-h6">Paramètres</v-col>
|
||||||
|
|
||||||
|
<v-col cols="6" md="4">
|
||||||
|
<v-switch inset v-model="editedItem.Settings.AutoPlay" label="AutoPlay" density="compact" color="primary"></v-switch>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6" md="4">
|
||||||
|
<v-switch inset v-model="editedItem.Settings.Loop" label="Loop" density="compact" color="primary"></v-switch>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6" md="4">
|
||||||
|
<v-text-field color="primary" variant="outlined" rounded="xl" v-model.number="editedItem.Settings.PlayTime" type="number" label="Durée (sec)" density="compact"></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12"><v-divider></v-divider></v-col>
|
||||||
|
<v-label class="text-title-style">
|
||||||
|
Réponse (Maître du jeu)
|
||||||
|
</v-label>
|
||||||
|
<v-col cols="12" class="text-h6"></v-col>
|
||||||
|
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-text-field color="primary" variant="outlined" rounded="xl" v-model="editedItem.MasterData.CorrectAnswer" label="Réponse Correcte"></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-textarea color="primary" variant="outlined" rounded="xl" v-model="editedItem.MasterData.MasterNotes" label="Notes pour le MJ" rows="2"></v-textarea>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-textarea color="primary" variant="outlined" rounded="xl" v-model="editedItem.MasterData.Help" label="Indice / Aide" rows="2"></v-textarea>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="primary" rounded="xl" variant="outlined" @click="close">Annuler</v-btn>
|
||||||
|
<v-btn color="success" rounded="xl" variant="outlined" @click="save">Ok</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Dialog Preview Image -->
|
||||||
|
<v-dialog v-model="showFullPreview" max-width="90vw">
|
||||||
|
<v-card rounded="xl" class="pa-2">
|
||||||
|
<v-card-text class="d-flex justify-center" style="padding: 16px;">
|
||||||
|
<v-img
|
||||||
|
:src="getPreviewUrl(editedItem.MediaUrl)"
|
||||||
|
max-height="80vh"
|
||||||
|
rounded="lg"
|
||||||
|
@click="showFullPreview = false"
|
||||||
|
style="cursor: pointer;"
|
||||||
|
></v-img>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="primary" variant="text" @click="showFullPreview = false">Fermer</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Dialog Preview Video -->
|
||||||
|
<v-dialog v-model="showVideoPreview" max-width="90vw">
|
||||||
|
<v-card rounded="xl" class="pa-2">
|
||||||
|
<v-card-text class="d-flex justify-center" style="padding: 16px;">
|
||||||
|
<video
|
||||||
|
:src="getPreviewUrl(editedItem.MediaUrl)"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
disablepictureinpicture
|
||||||
|
disableremoteplayback
|
||||||
|
controlslist="nodownload"
|
||||||
|
style="max-width: 100%; max-height: 80vh; border-radius: 12px;"
|
||||||
|
></video>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="primary" variant="text" @click="showVideoPreview = false">Fermer</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Dialog Preview Audio -->
|
||||||
|
<v-dialog v-model="showAudioPreview" max-width="500px">
|
||||||
|
<v-card rounded="xl" class="pa-4">
|
||||||
|
<v-card-title class="text-center">
|
||||||
|
<v-icon size="64" color="primary">mdi-music-circle</v-icon>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="d-flex justify-center" style="padding: 16px;">
|
||||||
|
<audio
|
||||||
|
:src="getPreviewUrl(editedItem.MediaUrl)"
|
||||||
|
controls
|
||||||
|
controlslist="nodownload"
|
||||||
|
disablepictureinpicture
|
||||||
|
disableremoteplayback
|
||||||
|
autoplay
|
||||||
|
style="width: 100%;"
|
||||||
|
></audio>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="primary" variant="text" @click="showAudioPreview = false">Fermer</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch, computed } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: Boolean,
|
||||||
|
question: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
sessionId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
apiUrl: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
nextIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'save', 'delete-media']);
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultQuestion = {
|
||||||
|
QuestionId: '',
|
||||||
|
Type: '',
|
||||||
|
Points: 1,
|
||||||
|
QuestionText: '',
|
||||||
|
MediaUrl: '',
|
||||||
|
Settings: {
|
||||||
|
StartAt: null,
|
||||||
|
StopAt: null,
|
||||||
|
AutoPlay: false,
|
||||||
|
Loop: false,
|
||||||
|
PlayTime: null,
|
||||||
|
DisplayMode: 'Cover',
|
||||||
|
BlurEffect: false
|
||||||
|
},
|
||||||
|
MasterData: {
|
||||||
|
CorrectAnswer: '',
|
||||||
|
MasterNotes: '',
|
||||||
|
Help: ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editedItem = reactive(JSON.parse(JSON.stringify(defaultQuestion)));
|
||||||
|
// Copy question data when opening
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (val) {
|
||||||
|
if (props.question) {
|
||||||
|
Object.assign(editedItem, JSON.parse(JSON.stringify(props.question)));
|
||||||
|
// Ensure nested objects exist
|
||||||
|
if(!editedItem.Settings) editedItem.Settings = {...defaultQuestion.Settings};
|
||||||
|
if(!editedItem.MasterData) editedItem.MasterData = {...defaultQuestion.MasterData};
|
||||||
|
} else {
|
||||||
|
Object.assign(editedItem, JSON.parse(JSON.stringify(defaultQuestion)));
|
||||||
|
// Auto-generate ID: Q-005 for 5th question
|
||||||
|
const idNumber = props.nextIndex.toString().padStart(3, '0');
|
||||||
|
editedItem.QuestionId = `Q-${idNumber}`;
|
||||||
|
}
|
||||||
|
uploadError.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isNew = computed(() => !props.question);
|
||||||
|
|
||||||
|
const showFullPreview = ref(false);
|
||||||
|
const showVideoPreview = ref(false);
|
||||||
|
const showAudioPreview = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const uploadError = ref('');
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
emit('save', JSON.parse(JSON.stringify(editedItem)));
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewUrl(relativePath) {
|
||||||
|
if (!relativePath || relativePath.startsWith('http')) return relativePath;
|
||||||
|
return `${props.apiUrl}/quizz/${props.sessionId}${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload Handling
|
||||||
|
const fileInputRef = ref(null);
|
||||||
|
|
||||||
|
function onFileSelected(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) handleFileUpload(file);
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(file) {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
uploading.value = true;
|
||||||
|
uploadError.value = '';
|
||||||
|
|
||||||
|
if (!editedItem.Type) {
|
||||||
|
uploadError.value = "Veuillez sélectionner un Type avant d'uploader un fichier.";
|
||||||
|
uploading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('questionId', editedItem.QuestionId);
|
||||||
|
formData.append('type', editedItem.Type);
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await axios.post(`${props.apiUrl}/upload/${props.sessionId}`, formData);
|
||||||
|
|
||||||
|
if (response.data && response.data.path) {
|
||||||
|
editedItem.MediaUrl = response.data.path;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Upload error:", e);
|
||||||
|
uploadError.value = "Erreur lors de l'upload du fichier.";
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteMedia() {
|
||||||
|
if (!editedItem.MediaUrl) return;
|
||||||
|
|
||||||
|
if (confirm('Voulez-vous vraiment supprimer ce média ? Cette action est irréversible.')) {
|
||||||
|
emit('delete-media', editedItem.MediaUrl);
|
||||||
|
editedItem.MediaUrl = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-title-style {
|
||||||
|
color: rgb(var(--v-theme-primary), 1) !important;
|
||||||
|
opacity: 1;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-box {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
background: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user