feat(Editor): Refactor liste des questions (SessionQuestionsList)
- Affichage liste avec previews média (Image/Vidéo/Audio) - Fix génération miniature vidéo via le hack #t=0.1 - Boutons de ré-ordonnancement (Monter/Descendre) - Intégration du dialogue d'édition QuestionEditorDialog
This commit is contained in:
256
VApp/src/components/SessionQuestionsList.vue
Normal file
256
VApp/src/components/SessionQuestionsList.vue
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<div class="session-questions-list">
|
||||||
|
<div class="d-flex justify-space-between align-center mb-5 px-4">
|
||||||
|
<span class="text-h6">Questions ({{ questions.length }})</span>
|
||||||
|
<v-btn rounded="xl" color="secondary" size="small" prepend-icon="mdi-plus" @click="openDialog()">Ajouter</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="questions.length === 0" class="text-h5 font-weight-bold text-center text-primary pb-5">
|
||||||
|
Aucune question définie.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List container without v-list wrapper for clean transition -->
|
||||||
|
<transition-group name="flip-list" tag="div" class="question-list-container">
|
||||||
|
<v-card v-for="(question, index) in questions" :key="question._ui_key" rounded="xl" variant="outlined" class="mb-2 question-item">
|
||||||
|
<v-card-text class="d-flex align-center py-2">
|
||||||
|
<div class="mr-4 font-weight-bold text-h6 text-primary">{{ index + 1 }}</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="text-subtitle-1 font-weight-bold">
|
||||||
|
<span class="text-primary opacity-100 pb-2">{{ question.QuestionId }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 font-weight-bold"><span class="text-primary opacity-100">Question :</span> {{ question.QuestionText }}</div>
|
||||||
|
<div class="text-body-2 font-weight-bold"><span class="text-primary opacity-100">Réponse :</span> {{ question.MasterData.CorrectAnswer }}</div>
|
||||||
|
<div class="text-body-2 font-weight-bold"><span class="text-primary opacity-100">Points :</span> {{ question.Points }}</div>
|
||||||
|
<div class="text-body-2 font-weight-bold"><span class="text-primary opacity-100">Temps :</span> {{ question.Settings.PlayTime }} secondes</div>
|
||||||
|
<div class="text-body-2 font-weight-bold"><span class="text-primary opacity-100">Type :</span> {{ question.Type }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="question.MediaUrl" class="mr-10">
|
||||||
|
<div class="list-preview-box" @click="openPreview(question.Type, question.MediaUrl)" style="cursor: pointer;">
|
||||||
|
<img v-if="question.Type === 'picture'" :src="getPreviewUrl(question.MediaUrl)" class="list-preview-content">
|
||||||
|
<video
|
||||||
|
v-else-if="question.Type === 'video'"
|
||||||
|
:src="getPreviewUrl(question.MediaUrl) + '#t=0.1'"
|
||||||
|
class="list-preview-content"
|
||||||
|
preload="metadata"
|
||||||
|
muted
|
||||||
|
></video>
|
||||||
|
<div v-else-if="question.Type === 'audio'">
|
||||||
|
<v-icon size="32" color="primary">mdi-music</v-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-1">
|
||||||
|
<v-btn icon="mdi-arrow-up" size="x-large" variant="text" :disabled="index === 0" @click="$emit('move', index, -1)"></v-btn>
|
||||||
|
<v-btn icon="mdi-arrow-down" size="x-large" variant="text" :disabled="index === questions.length - 1" @click="$emit('move', index, 1)"></v-btn>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex ml-2">
|
||||||
|
<v-btn icon="mdi-pencil" size="x-large" variant="text" color="blue" @click="openDialog(question, index)"></v-btn>
|
||||||
|
<v-btn icon="mdi-delete" size="x-large" variant="text" color="red" @click="$emit('delete', index)"></v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</transition-group>
|
||||||
|
|
||||||
|
<!-- Use persistent prop if needed, though hidden by v-model -->
|
||||||
|
<QuestionEditorDialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:question="editingQuestion"
|
||||||
|
:session-id="sessionId"
|
||||||
|
:api-url="apiUrl"
|
||||||
|
:next-index="nextQuestionIndex"
|
||||||
|
@save="onSave"
|
||||||
|
@delete-media="onDeleteMedia"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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(previewMediaUrl)"
|
||||||
|
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(previewMediaUrl)"
|
||||||
|
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(previewMediaUrl)"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import QuestionEditorDialog from './QuestionEditorDialog.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
questions: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
sessionId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
apiUrl: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['move', 'delete', 'update', 'delete-media']);
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const editingQuestion = ref(null);
|
||||||
|
const editingIndex = ref(-1);
|
||||||
|
const nextQuestionIndex = ref(1);
|
||||||
|
|
||||||
|
function openDialog(question = null, index = -1) {
|
||||||
|
editingQuestion.value = question; // if null, dialog treats as new
|
||||||
|
editingIndex.value = index;
|
||||||
|
|
||||||
|
// Calculate next index: if editing, use current index + 1, else use length + 1
|
||||||
|
if (index >= 0) {
|
||||||
|
nextQuestionIndex.value = index + 1;
|
||||||
|
} else {
|
||||||
|
nextQuestionIndex.value = props.questions.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSave(questionData) {
|
||||||
|
emit('update', questionData, editingIndex.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteMedia(mediaUrl) {
|
||||||
|
emit('delete-media', mediaUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewUrl(relativePath) {
|
||||||
|
if (!relativePath || relativePath.startsWith('http')) return relativePath;
|
||||||
|
return `${props.apiUrl}/quizz/${props.sessionId}${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showFullPreview = ref(false);
|
||||||
|
const showVideoPreview = ref(false);
|
||||||
|
const showAudioPreview = ref(false);
|
||||||
|
const previewMediaUrl = ref('');
|
||||||
|
|
||||||
|
function openPreview(type, url) {
|
||||||
|
previewMediaUrl.value = url;
|
||||||
|
if (type === 'picture') showFullPreview.value = true;
|
||||||
|
else if (type === 'video') showVideoPreview.value = true;
|
||||||
|
else if (type === 'audio') showAudioPreview.value = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gap-1 {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-list-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-item {
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
/* Ensure z-index is handled during move for better visual stack */
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition d'animation de liste */
|
||||||
|
.flip-list-move {
|
||||||
|
transition: transform 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entering items */
|
||||||
|
.flip-list-enter-active,
|
||||||
|
.flip-list-leave-active {
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-list-enter-from,
|
||||||
|
.flip-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure removed items are taken out of flow so others can move up smoothly */
|
||||||
|
.flip-list-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%; /* Important to maintain width when absolute */
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-preview-box {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-preview-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user