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