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:
2026-02-08 16:43:55 +01:00
parent 1ce14eca13
commit cdb3cdf642

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