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:
2026-02-08 16:41:49 +01:00
parent eae80c0c39
commit 46bd3f5917

View 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>