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