Compare commits
19 Commits
update/imp
...
31649435a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 31649435a6 | |||
| 0f0f1ffe33 | |||
| 1c2c8dfcbf | |||
| ed9a939121 | |||
| a15d811092 | |||
| bab961ace7 | |||
| 07d76a7669 | |||
| 3f63801df9 | |||
| 66c9e68eb7 | |||
| cc9cf987b1 | |||
| 212e2f350f | |||
| 5379e0ed53 | |||
| 6403b8a299 | |||
| 827427ed28 | |||
| 4efe3b00c4 | |||
| a844c21a1b | |||
| f7e2a7a37e | |||
| 4c1fac7543 | |||
| 98b084724e |
@@ -81,6 +81,9 @@
|
|||||||
client.on('connect', () => {
|
client.on('connect', () => {
|
||||||
console.log('CardButtonScore: Connected to MQTT broker at', config.mqttBrokerUrl);
|
console.log('CardButtonScore: Connected to MQTT broker at', config.mqttBrokerUrl);
|
||||||
client.subscribe('game/score');
|
client.subscribe('game/score');
|
||||||
|
|
||||||
|
console.log("CardButtonScore: Requesting scores.");
|
||||||
|
client.publish('game/score/request', '{}');
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('error', (err) => {
|
client.on('error', (err) => {
|
||||||
|
|||||||
@@ -91,7 +91,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, reactive } from 'vue';
|
import { onMounted, ref, reactive } from 'vue';
|
||||||
import variables from '@/variables.js';
|
|
||||||
import mqtt from 'mqtt'
|
import mqtt from 'mqtt'
|
||||||
import config from '@/config.js'
|
import config from '@/config.js'
|
||||||
|
|
||||||
@@ -131,24 +130,6 @@ function handleMessage(topic, message) {
|
|||||||
scores.BlueRoundScore = parsedMessage.TEAM.Blue.RoundScore
|
scores.BlueRoundScore = parsedMessage.TEAM.Blue.RoundScore
|
||||||
scores.YellowRoundScore = parsedMessage.TEAM.Yellow.RoundScore
|
scores.YellowRoundScore = parsedMessage.TEAM.Yellow.RoundScore
|
||||||
scores.GreenRoundScore = parsedMessage.TEAM.Green.RoundScore
|
scores.GreenRoundScore = parsedMessage.TEAM.Green.RoundScore
|
||||||
// Mettre à jour l'état des buzzers en fonction des messages
|
|
||||||
/*
|
|
||||||
switch (buzzer) {
|
|
||||||
case 'redBuzzerIP':
|
|
||||||
redBuzzerState.value = status === "online" ? 1 : 0;
|
|
||||||
break;
|
|
||||||
case 'blueBuzzerIP':
|
|
||||||
blueBuzzerState.value = status === "online" ? 1 : 0;
|
|
||||||
break;
|
|
||||||
case 'yellowBuzzerIP':
|
|
||||||
yellowBuzzerState.value = status === "online" ? 1 : 0;
|
|
||||||
break;
|
|
||||||
case 'greenBuzzerIP':
|
|
||||||
greenBuzzerState.value = status === "online" ? 1 : 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +143,9 @@ onMounted(() => {
|
|||||||
subscribeToTopic('game/score', (topic, message) => {
|
subscribeToTopic('game/score', (topic, message) => {
|
||||||
handleMessage(topic, message);
|
handleMessage(topic, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Request score refresh
|
||||||
|
client.publish('game/score/request', '{}');
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
const disabled = ref(false)
|
const disabled = ref(false)
|
||||||
|
|
||||||
const _publishMessage = () => {
|
const _publishMessage = () => {
|
||||||
|
console.log('MqttButton: Publishing', props.topic, props.message)
|
||||||
publishMessage(props.topic, props.message)
|
publishMessage(props.topic, props.message)
|
||||||
disabled.value = true
|
disabled.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,14 @@
|
|||||||
<v-container class="score_div color-blue">
|
<v-container class="score_div color-blue">
|
||||||
<div class="d-flex flex-column align-center">
|
<div class="d-flex flex-column align-center">
|
||||||
<Transition name="score-fade" mode="out-in">
|
<Transition name="score-fade" mode="out-in">
|
||||||
<span :key="scores.BlueRoundScore" class="v-label-round-score">Manche : {{ scores.BlueRoundScore }}</span>
|
<span :key="scores.BlueTotalScore" class="v-label-score">{{ scores.BlueRoundScore }}</span>
|
||||||
</Transition>
|
|
||||||
<Transition name="score-fade" mode="out-in">
|
|
||||||
<span :key="scores.BlueTotalScore" class="v-label-score">{{ scores.BlueTotalScore }}</span>
|
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
<v-container class="score_div color-red">
|
<v-container class="score_div color-red">
|
||||||
<div class="d-flex flex-column align-center">
|
<div class="d-flex flex-column align-center">
|
||||||
<Transition name="score-fade" mode="out-in">
|
<Transition name="score-fade" mode="out-in">
|
||||||
<span :key="scores.RedRoundScore" class="v-label-round-score">Manche : {{ scores.RedRoundScore }}</span>
|
<span :key="scores.RedTotalScore" class="v-label-score">{{ scores.RedRoundScore }}</span>
|
||||||
</Transition>
|
|
||||||
<Transition name="score-fade" mode="out-in">
|
|
||||||
<span :key="scores.RedTotalScore" class="v-label-score">{{ scores.RedTotalScore }}</span>
|
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
@@ -28,20 +22,14 @@
|
|||||||
<v-container class="score_div color-green">
|
<v-container class="score_div color-green">
|
||||||
<div class="d-flex flex-column align-center">
|
<div class="d-flex flex-column align-center">
|
||||||
<Transition name="score-fade" mode="out-in">
|
<Transition name="score-fade" mode="out-in">
|
||||||
<span :key="scores.GreenRoundScore" class="v-label-round-score">Manche : {{ scores.GreenRoundScore }}</span>
|
<span :key="scores.GreenTotalScore" class="v-label-score">{{ scores.GreenRoundScore }}</span>
|
||||||
</Transition>
|
|
||||||
<Transition name="score-fade" mode="out-in">
|
|
||||||
<span :key="scores.GreenTotalScore" class="v-label-score">{{ scores.GreenTotalScore }}</span>
|
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
<v-container class="score_div color-yellow">
|
<v-container class="score_div color-yellow">
|
||||||
<div class="d-flex flex-column align-center">
|
<div class="d-flex flex-column align-center">
|
||||||
<Transition name="score-fade" mode="out-in">
|
<Transition name="score-fade" mode="out-in">
|
||||||
<span :key="scores.YellowRoundScore" class="v-label-round-score">Manche : {{ scores.YellowRoundScore }}</span>
|
<span :key="scores.YellowTotalScore" class="v-label-score">{{ scores.YellowRoundScore }}</span>
|
||||||
</Transition>
|
|
||||||
<Transition name="score-fade" mode="out-in">
|
|
||||||
<span :key="scores.YellowTotalScore" class="v-label-score">{{ scores.YellowTotalScore }}</span>
|
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
@@ -56,7 +44,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
//import VideoPlayer from "@/components/VideoPlayer.vue"
|
|
||||||
import GameMedia from "@/components/GameMedia.vue"
|
import GameMedia from "@/components/GameMedia.vue"
|
||||||
import HidingOverlay from "@/components/HidingOverlay.vue"
|
import HidingOverlay from "@/components/HidingOverlay.vue"
|
||||||
import { onMounted, reactive } from 'vue';
|
import { onMounted, reactive } from 'vue';
|
||||||
@@ -64,9 +51,11 @@
|
|||||||
import config from '@/config.js'
|
import config from '@/config.js'
|
||||||
import quizStore from '@/store/quizStore';
|
import quizStore from '@/store/quizStore';
|
||||||
|
|
||||||
|
// Configuration MQTT
|
||||||
const mqttBrokerUrl = config.mqttBrokerUrl
|
const mqttBrokerUrl = config.mqttBrokerUrl
|
||||||
const client = mqtt.connect(mqttBrokerUrl)
|
const client = mqtt.connect(mqttBrokerUrl)
|
||||||
|
|
||||||
|
// Objet réactif pour stocker les scores des équipes
|
||||||
const scores = reactive({
|
const scores = reactive({
|
||||||
RedTotalScore: 0,
|
RedTotalScore: 0,
|
||||||
BlueTotalScore: 0,
|
BlueTotalScore: 0,
|
||||||
@@ -79,14 +68,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
// Variable réactive pour l'affichage du timer
|
||||||
const timerDisplay = ref('00:00');
|
const timerDisplay = ref('00:00');
|
||||||
|
|
||||||
|
// Fonction pour formater le temps en mm:ss
|
||||||
function formatTime(seconds) {
|
function formatTime(seconds) {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = seconds % 60;
|
const secs = seconds % 60;
|
||||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fonction de gestion des messages MQTT reçus
|
||||||
function handleMessage(topic, message) {
|
function handleMessage(topic, message) {
|
||||||
let parsedMessage;
|
let parsedMessage;
|
||||||
try {
|
try {
|
||||||
@@ -96,6 +88,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mise à jour des scores si le message vient du topic 'game/score'
|
||||||
if (topic === 'game/score' && parsedMessage.TEAM) {
|
if (topic === 'game/score' && parsedMessage.TEAM) {
|
||||||
scores.RedTotalScore = parsedMessage.TEAM.Red.TotalScore
|
scores.RedTotalScore = parsedMessage.TEAM.Red.TotalScore
|
||||||
scores.BlueTotalScore = parsedMessage.TEAM.Blue.TotalScore
|
scores.BlueTotalScore = parsedMessage.TEAM.Blue.TotalScore
|
||||||
@@ -108,11 +101,13 @@
|
|||||||
scores.GreenRoundScore = parsedMessage.TEAM.Green.RoundScore
|
scores.GreenRoundScore = parsedMessage.TEAM.Green.RoundScore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mise à jour du timer si le message vient du topic 'game/timer'
|
||||||
if (topic === 'game/timer' && parsedMessage.time !== undefined) {
|
if (topic === 'game/timer' && parsedMessage.time !== undefined) {
|
||||||
timerDisplay.value = formatTime(parsedMessage.time);
|
timerDisplay.value = formatTime(parsedMessage.time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fonction utilitaire pour s'abonner à un topic MQTT
|
||||||
function subscribeToTopic(topic, callback) {
|
function subscribeToTopic(topic, callback) {
|
||||||
client.subscribe(topic)
|
client.subscribe(topic)
|
||||||
client.on('message', (receivedTopic, message) => { callback(receivedTopic.toString(), message.toString())
|
client.on('message', (receivedTopic, message) => { callback(receivedTopic.toString(), message.toString())
|
||||||
@@ -120,14 +115,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Initialisation du store du quiz
|
||||||
quizStore.actions.init();
|
quizStore.actions.init();
|
||||||
|
|
||||||
|
// Abonnement aux topics MQTT pour les scores et le timer
|
||||||
subscribeToTopic('game/score', (topic, message) => {
|
subscribeToTopic('game/score', (topic, message) => {
|
||||||
handleMessage(topic, message);
|
handleMessage(topic, message);
|
||||||
});
|
});
|
||||||
subscribeToTopic('game/timer', (topic, message) => {
|
subscribeToTopic('game/timer', (topic, message) => {
|
||||||
handleMessage(topic, message);
|
handleMessage(topic, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Demande de rafraîchissement des scores au chargement
|
||||||
|
// Cela permet de récupérer les scores actuels même après un rechargement de page
|
||||||
|
client.publish('game/score/request', '{}');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="score-main">
|
<div class="score-main">
|
||||||
<div class="team-score main-score">{{ scores.BlueRoundScore }}</div>
|
<Transition name="score-pop" mode="out-in">
|
||||||
|
<div :key="scores.BlueRoundScore" class="team-score main-score">{{ scores.BlueRoundScore }}</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="score-cell cell-red color-red">
|
<div class="score-cell cell-red color-red">
|
||||||
<div class="score-content">
|
<div class="score-content">
|
||||||
<div class="score-main">
|
<div class="score-main">
|
||||||
<div class="team-score main-score">{{ scores.RedRoundScore }}</div>
|
<Transition name="score-pop" mode="out-in">
|
||||||
|
<div :key="scores.RedRoundScore" class="team-score main-score">{{ scores.RedRoundScore }}</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
<div class="score-info info-right">
|
<div class="score-info info-right">
|
||||||
<div class="team-name">Rouge</div>
|
<div class="team-name">Rouge</div>
|
||||||
@@ -38,14 +42,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="score-main">
|
<div class="score-main">
|
||||||
<div class="team-score main-score">{{ scores.GreenRoundScore }}</div>
|
<Transition name="score-pop" mode="out-in">
|
||||||
|
<div :key="scores.GreenRoundScore" class="team-score main-score">{{ scores.GreenRoundScore }}</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="score-cell cell-yellow color-yellow">
|
<div class="score-cell cell-yellow color-yellow">
|
||||||
<div class="score-content">
|
<div class="score-content">
|
||||||
<div class="score-main">
|
<div class="score-main">
|
||||||
<div class="team-score main-score">{{ scores.YellowRoundScore }}</div>
|
<Transition name="score-pop" mode="out-in">
|
||||||
|
<div :key="scores.YellowRoundScore" class="team-score main-score">{{ scores.YellowRoundScore }}</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
<div class="score-info info-right">
|
<div class="score-info info-right">
|
||||||
<div class="team-name">Jaune</div>
|
<div class="team-name">Jaune</div>
|
||||||
@@ -132,6 +140,8 @@
|
|||||||
subscribeToTopic('game/timer', (topic, message) => {
|
subscribeToTopic('game/timer', (topic, message) => {
|
||||||
handleMessage(topic, message);
|
handleMessage(topic, message);
|
||||||
});
|
});
|
||||||
|
// Request score refresh
|
||||||
|
client.publish('game/score/request', '{}');
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -299,4 +309,34 @@
|
|||||||
.color-yellow {
|
.color-yellow {
|
||||||
background: linear-gradient(135deg, rgb(var(--v-theme-YellowBuzzer)), #5a5a1a);
|
background: linear-gradient(135deg, rgb(var(--v-theme-YellowBuzzer)), #5a5a1a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Score Pop Animation */
|
||||||
|
.score-pop-enter-active {
|
||||||
|
animation: pop-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
}
|
||||||
|
.score-pop-leave-active {
|
||||||
|
animation: pop-out 0.2s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pop-in {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pop-out {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
610
VApp/src/views/SessionEditor.vue
Normal file
610
VApp/src/views/SessionEditor.vue
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
<template>
|
||||||
|
<v-container>
|
||||||
|
<v-row class="mb-4 align-center">
|
||||||
|
<v-col cols="12" md="4" class="d-flex align-center gap-2">
|
||||||
|
<v-select
|
||||||
|
v-model="selectedSessionId"
|
||||||
|
:items="availableSessions"
|
||||||
|
item-title="title"
|
||||||
|
item-value="id"
|
||||||
|
label="Choisir une session"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
@update:model-value="loadSession"
|
||||||
|
rounded="xl"
|
||||||
|
class="flex-grow-1"
|
||||||
|
></v-select>
|
||||||
|
<v-btn icon="mdi-plus" color="green" variant="tonal" @click="openCreateDialog" title="Nouvelle Session"></v-btn>
|
||||||
|
<v-btn icon="mdi-delete" color="red" variant="tonal" @click="deleteDialogVisible = true" :disabled="!selectedSessionId" title="Supprimer Session"></v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col>
|
||||||
|
<h1 class="text-h4 white--text">Éditeur</h1>
|
||||||
|
</v-col>
|
||||||
|
<v-col class="text-right">
|
||||||
|
<v-btn rounded="xl" color="primary" @click="saveSession" :loading="saving" :disabled="!selectedSessionId">
|
||||||
|
<v-icon start>mdi-content-save</v-icon> Sauvegarder
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<div v-if="!selectedSessionId" class="text-center mt-10 text-h5 grey--text">
|
||||||
|
Veuillez sélectionner une session pour commencer.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<v-alert v-if="error" type="error" closable class="mb-4">{{ error }}</v-alert>
|
||||||
|
<v-alert rounded="xl" v-if="success" type="success" closable class="mb-4">{{ success }}</v-alert>
|
||||||
|
|
||||||
|
<v-card class="mb-6" rounded="xl">
|
||||||
|
<v-card-title>Configuration Générale</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field color="primary" v-model="sessionConfig.PackId" label="ID du Pack" readonly variant="outlined" rounded="xl"></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field color="primary" v-model="sessionConfig.PackTitle" label="Titre du Pack" variant="outlined" rounded="xl"></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex justify-space-between align-center">
|
||||||
|
Questions ({{ sessionConfig.Questions.length }})
|
||||||
|
<v-btn color="secondary" size="small" prepend-icon="mdi-plus" @click="openQuestionDialog()">Ajouter</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list lines="two" bg-color="transparent">
|
||||||
|
<v-list-item v-if="sessionConfig.Questions.length === 0">
|
||||||
|
<v-list-item-title class="text-center">Aucune question définie.</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<template v-for="(question, index) in sessionConfig.Questions" :key="question.QuestionId">
|
||||||
|
<v-card variant="outlined" class="mb-2">
|
||||||
|
<v-card-text class="d-flex align-center py-2">
|
||||||
|
<div class="mr-4 font-weight-bold text-h6">{{ index + 1 }}</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="text-subtitle-1 font-weight-bold">
|
||||||
|
{{ question.QuestionId }} - {{ question.Type }} ({{ question.Points }} pts)
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2">{{ question.QuestionText }}</div>
|
||||||
|
<div class="text-caption grey--text">Réponse: {{ question.MasterData.CorrectAnswer }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column gap-1">
|
||||||
|
<v-btn icon="mdi-arrow-up" size="x-small" variant="text" :disabled="index === 0" @click="moveQuestion(index, -1)"></v-btn>
|
||||||
|
<v-btn icon="mdi-arrow-down" size="x-small" variant="text" :disabled="index === sessionConfig.Questions.length - 1" @click="moveQuestion(index, 1)"></v-btn>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex ml-2">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" color="blue" @click="openQuestionDialog(question, index)"></v-btn>
|
||||||
|
<v-btn icon="mdi-delete" size="small" variant="text" color="red" @click="deleteQuestion(index)"></v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dialog d'édition de question -->
|
||||||
|
<v-dialog v-model="dialogVisible" persistent max-width="800px">
|
||||||
|
<v-card rounded="xl" class="pa-3">
|
||||||
|
<v-card-title class="text-title-style">
|
||||||
|
{{ editingIndex === -1 ? '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="lg" v-model="editedQuestion.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="lg" v-model="editedQuestion.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="lg" v-model.number="editedQuestion.Points" type="number" label="Points"></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-textarea color="primary" variant="outlined" rounded="lg" v-model="editedQuestion.QuestionText" label="Texte de la question" rows="2"></v-textarea>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<!-- Media Section -->
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="d-flex align-end">
|
||||||
|
<!-- Preview Section -->
|
||||||
|
<div v-if="editedQuestion.MediaUrl" class="mr-4 text-center" style="width: 120px; height: 120px; background: #333; display: flex; align-items: center; justify-content: center; overflow: hidden; border-radius: 8px; position: relative;">
|
||||||
|
<img v-if="editedQuestion.Type === 'picture'" :src="getPreviewUrl(editedQuestion.MediaUrl)" style="width: 100%; height: 100%; object-fit: cover; cursor: pointer;" @click="showFullPreview = true">
|
||||||
|
<video
|
||||||
|
v-else-if="editedQuestion.Type === 'video'"
|
||||||
|
:src="getPreviewUrl(editedQuestion.MediaUrl)"
|
||||||
|
style="width: 100%; height: 100%; object-fit: cover; cursor: pointer;"
|
||||||
|
@click="showVideoPreview = true"
|
||||||
|
@loadedmetadata="(e) => e.target.currentTime = e.target.duration / 3"
|
||||||
|
muted
|
||||||
|
></video>
|
||||||
|
<div v-else-if="editedQuestion.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="lg" v-model="editedQuestion.MediaUrl" label="URL du Média" class="flex-grow-2 mr-2" hide-details></v-text-field>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="fileInputRef"
|
||||||
|
style="display: none;"
|
||||||
|
@change="onFileSelected"
|
||||||
|
accept="image/*,video/*,audio/*"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
rounded="lg"
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
:loading="uploading"
|
||||||
|
:disabled="!editedQuestion.Type"
|
||||||
|
@click="$refs.fileInputRef.click()"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-upload</v-icon>
|
||||||
|
Upload
|
||||||
|
</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="editedQuestion.Settings.AutoPlay" label="AutoPlay" density="compact" color="primary"></v-switch>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6" md="4">
|
||||||
|
<v-switch inset v-model="editedQuestion.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="lg" v-model.number="editedQuestion.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="lg" v-model="editedQuestion.MasterData.CorrectAnswer" label="Réponse Correcte"></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-textarea color="primary" variant="outlined" rounded="lg" v-model="editedQuestion.MasterData.MasterNotes" label="Notes pour le MJ" rows="2"></v-textarea>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-textarea color="primary" variant="outlined" rounded="lg" v-model="editedQuestion.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="closeDialog">Annuler</v-btn>
|
||||||
|
<v-btn color="success" rounded="xl" variant="outlined" @click="saveQuestion">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(editedQuestion.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(editedQuestion.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(editedQuestion.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>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Dialog Création de session -->
|
||||||
|
<v-dialog v-model="createDialogVisible" persistent max-width="500px">
|
||||||
|
<v-card rounded="xl">
|
||||||
|
<v-card-title class="text-h5">Nouvelle Session</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field
|
||||||
|
v-model="newSessionName"
|
||||||
|
label="Nom de la session"
|
||||||
|
variant="outlined"
|
||||||
|
autofocus
|
||||||
|
@keyup.enter="createSession"
|
||||||
|
></v-text-field>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="grey" variant="text" @click="createDialogVisible = false">Annuler</v-btn>
|
||||||
|
<v-btn color="green" variant="elevated" @click="createSession" :disabled="!newSessionName">Créer</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Dialog Suppression de session -->
|
||||||
|
<v-dialog v-model="deleteDialogVisible" max-width="400px">
|
||||||
|
<v-card rounded="xl">
|
||||||
|
<v-card-title class="text-h5 delete-dialog-title-style">Supprimer la session ?</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
Cette action est irréversible ! Tous les fichiers de cette session seront supprimés.
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn rounded="xl" class="mb-3" color="grey" variant="text" @click="deleteDialogVisible = false">Annuler</v-btn>
|
||||||
|
<v-btn rounded="xl" class="mb-3 mr-3" color="red" variant="elevated" @click="confirmDeleteSession">Supprimer</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||||
|
import mqtt from 'mqtt';
|
||||||
|
import config from '@/config.js';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const availableSessions = ref([]);
|
||||||
|
const selectedSessionId = ref(null);
|
||||||
|
|
||||||
|
const sessionConfig = reactive({
|
||||||
|
PackId: '',
|
||||||
|
PackTitle: '',
|
||||||
|
Questions: []
|
||||||
|
});
|
||||||
|
|
||||||
|
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 dialogVisible = ref(false);
|
||||||
|
const editingIndex = ref(-1);
|
||||||
|
const editedQuestion = reactive(JSON.parse(JSON.stringify(defaultQuestion)));
|
||||||
|
|
||||||
|
// Creation Session
|
||||||
|
const createDialogVisible = ref(false);
|
||||||
|
const newSessionName = ref('');
|
||||||
|
const deleteDialogVisible = ref(false);
|
||||||
|
const showFullPreview = ref(false);
|
||||||
|
const showVideoPreview = ref(false);
|
||||||
|
const showAudioPreview = ref(false);
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const success = ref('');
|
||||||
|
const uploadError = ref('');
|
||||||
|
|
||||||
|
// MQTT
|
||||||
|
const mqttBrokerUrl = config.mqttBrokerUrl;
|
||||||
|
// Guess HTTP URL from MQTT URL (assuming typical setup or localhost fallback)
|
||||||
|
// Ideally this should be in config too
|
||||||
|
const httpBaseUrl = mqttBrokerUrl.replace('ws://', 'http://').replace('wss://', 'https://').split(':')[0] + ':' + (config.mqttBrokerUrl.includes('localhost') ? '3000' : '3000');
|
||||||
|
// Warning: This URL construction is brittle. Better to put api URL in config. But for now we assume port 3000 next to MQTT.
|
||||||
|
// Correct logic: take protocol://hostname from mqtt, add :3000.
|
||||||
|
function getApiUrl() {
|
||||||
|
// Port 3001 to avoid conflict with Vite (3000)
|
||||||
|
// Use the same hostname as the current page to avoid "Private Network Access" blocks
|
||||||
|
return `http://192.168.1.178:3001`;
|
||||||
|
}
|
||||||
|
const API_URL = getApiUrl();
|
||||||
|
|
||||||
|
function getPreviewUrl(relativePath) {
|
||||||
|
if (!relativePath || relativePath.startsWith('http')) return relativePath;
|
||||||
|
// Construct full URL: API_URL + /quizz/ + sessionId + relativePath
|
||||||
|
// relativePath starts with /assets/...
|
||||||
|
return `${API_URL}/quizz/${selectedSessionId.value}${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
const topics = {
|
||||||
|
requestList: 'game/session/list/request',
|
||||||
|
responseList: 'game/session/list/response',
|
||||||
|
requestConfig: 'game/session/config/request',
|
||||||
|
getConfig: 'game/session/config/get',
|
||||||
|
updateConfig: 'game/session/config/update',
|
||||||
|
createSession: 'game/session/create',
|
||||||
|
deleteSession: 'game/session/delete'
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
client = mqtt.connect(mqttBrokerUrl);
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
console.log('SessionEditor: Connected to MQTT');
|
||||||
|
client.subscribe(topics.responseList);
|
||||||
|
client.subscribe(topics.getConfig);
|
||||||
|
|
||||||
|
// Request list of sessions
|
||||||
|
client.publish(topics.requestList, '{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('message', (topic, message) => {
|
||||||
|
if (topic === topics.responseList) {
|
||||||
|
try {
|
||||||
|
availableSessions.value = JSON.parse(message.toString());
|
||||||
|
console.log("Sessions received:", availableSessions.value);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
else if (topic === topics.getConfig) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.toString());
|
||||||
|
// Reset config
|
||||||
|
sessionConfig.Questions = [];
|
||||||
|
Object.assign(sessionConfig, data);
|
||||||
|
console.log('Session config loaded');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing session config', e);
|
||||||
|
error.value = "Erreur lors du chargement de la configuration.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (client) client.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadSession() {
|
||||||
|
if (!selectedSessionId.value) return;
|
||||||
|
console.log("Loading session:", selectedSessionId.value);
|
||||||
|
client.publish(topics.requestConfig, JSON.stringify({ SessionId: selectedSessionId.value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openQuestionDialog(item, index) {
|
||||||
|
if (item) {
|
||||||
|
editingIndex.value = index;
|
||||||
|
Object.assign(editedQuestion, JSON.parse(JSON.stringify(item)));
|
||||||
|
if(!editedQuestion.Settings) editedQuestion.Settings = {...defaultQuestion.Settings};
|
||||||
|
if(!editedQuestion.MasterData) editedQuestion.MasterData = {...defaultQuestion.MasterData};
|
||||||
|
} else {
|
||||||
|
editingIndex.value = -1;
|
||||||
|
Object.assign(editedQuestion, JSON.parse(JSON.stringify(defaultQuestion)));
|
||||||
|
editedQuestion.QuestionId = "Q-NEW";
|
||||||
|
}
|
||||||
|
dialogVisible.value = true;
|
||||||
|
uploadError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
dialogVisible.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
Object.assign(editedQuestion, JSON.parse(JSON.stringify(defaultQuestion)));
|
||||||
|
editingIndex.value = -1;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveQuestion() {
|
||||||
|
if (editingIndex.value > -1) {
|
||||||
|
Object.assign(sessionConfig.Questions[editingIndex.value], editedQuestion);
|
||||||
|
} else {
|
||||||
|
sessionConfig.Questions.push(JSON.parse(JSON.stringify(editedQuestion)));
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteQuestion(index) {
|
||||||
|
if (confirm('Êtes-vous sûr de vouloir supprimer cette question ?')) {
|
||||||
|
sessionConfig.Questions.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveQuestion(index, direction) {
|
||||||
|
const newIndex = index + direction;
|
||||||
|
if (newIndex >= 0 && newIndex < sessionConfig.Questions.length) {
|
||||||
|
const item = sessionConfig.Questions.splice(index, 1)[0];
|
||||||
|
sessionConfig.Questions.splice(newIndex, 0, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelected(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(file);
|
||||||
|
}
|
||||||
|
// Reset input so same file can be selected again
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(files) {
|
||||||
|
if (!files) return;
|
||||||
|
// Vuetify v-file-input can return a single object or an array depending on version/props
|
||||||
|
const file = Array.isArray(files) ? files[0] : files;
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
uploading.value = true;
|
||||||
|
uploadError.value = '';
|
||||||
|
|
||||||
|
// Validation: Type is mandatory for folder sorting
|
||||||
|
if (!editedQuestion.Type) {
|
||||||
|
uploadError.value = "Veuillez sélectionner un Type avant d'uploader un fichier.";
|
||||||
|
uploading.value = false;
|
||||||
|
// Reset input (optional but cleanest)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
// Append metadata BEFORE file so multer can read them first for destination/filename
|
||||||
|
formData.append('questionId', editedQuestion.QuestionId);
|
||||||
|
formData.append('type', editedQuestion.Type);
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
// Upload to backend
|
||||||
|
const response = await axios.post(`${API_URL}/upload/${selectedSessionId.value}`, formData);
|
||||||
|
|
||||||
|
if (response.data && response.data.path) {
|
||||||
|
editedQuestion.MediaUrl = response.data.path;
|
||||||
|
console.log("File uploaded successfully:", response.data.path);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Upload error:", e);
|
||||||
|
uploadError.value = "Erreur lors de l'upload du fichier.";
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
newSessionName.value = '';
|
||||||
|
createDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession() {
|
||||||
|
if (!newSessionName.value) return;
|
||||||
|
|
||||||
|
console.log("Creating session:", newSessionName.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({ SessionName: newSessionName.value });
|
||||||
|
client.publish(topics.createSession, payload);
|
||||||
|
|
||||||
|
success.value = "Demande de création envoyée...";
|
||||||
|
setTimeout(() => success.value = '', 3000);
|
||||||
|
|
||||||
|
createDialogVisible.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error creating session", e);
|
||||||
|
error.value = "Erreur lors de la création.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteSession() {
|
||||||
|
if (!selectedSessionId.value) return;
|
||||||
|
|
||||||
|
console.log("Deleting session:", selectedSessionId.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({ SessionId: selectedSessionId.value });
|
||||||
|
client.publish(topics.deleteSession, payload);
|
||||||
|
|
||||||
|
success.value = "Demande de suppression envoyée...";
|
||||||
|
setTimeout(() => success.value = '', 3000);
|
||||||
|
|
||||||
|
deleteDialogVisible.value = false;
|
||||||
|
selectedSessionId.value = null;
|
||||||
|
sessionConfig.Questions = [];
|
||||||
|
sessionConfig.PackId = '';
|
||||||
|
sessionConfig.PackTitle = '';
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error deleting session", e);
|
||||||
|
error.value = "Erreur lors de la suppression.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSession() {
|
||||||
|
if (!selectedSessionId.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
success.value = '';
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
SessionId: selectedSessionId.value,
|
||||||
|
Config: sessionConfig
|
||||||
|
});
|
||||||
|
client.publish(topics.updateConfig, payload);
|
||||||
|
success.value = "Sauvegarde envoyée.";
|
||||||
|
setTimeout(() => success.value = '', 3000);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = "Erreur lors de la sauvegarde : " + e.message;
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gap-1 {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.gap-2 {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.delete-dialog-title-style {
|
||||||
|
color: rgb(var(--v-theme-primary)) !important;
|
||||||
|
padding-left: 6%;
|
||||||
|
padding-top: 3%;
|
||||||
|
}
|
||||||
|
.text-title-style {
|
||||||
|
color: rgb(var(--v-theme-primary), 1) !important;
|
||||||
|
opacity: 1;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
84
VHard/affichage_score_kiosque.md
Normal file
84
VHard/affichage_score_kiosque.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Documentation Déploiement Kiosque - Tableau de Score
|
||||||
|
|
||||||
|
Ce document décrit la configuration du serveur Fedora pour lancer automatiquement Google Chrome en mode plein écran au démarrage via un compositeur Wayland minimaliste (Cage).
|
||||||
|
|
||||||
|
## 1. Installation des dépendances
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm
|
||||||
|
sudo dnf install -y cage
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Configuration de l'Autologin (Systemd)
|
||||||
|
|
||||||
|
Créer le fichier d'override pour que le serveur se connecte seul sur le TTY1 :
|
||||||
|
`sudo systemctl edit getty@tty1.service`
|
||||||
|
|
||||||
|
Coller le contenu suivant :
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=-/sbin/agetty --autologin VOTRE_USER --noclear %I $TERM
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Script de lancement et Watchdog
|
||||||
|
|
||||||
|
Créer un script nommé `kiosk-waiter.sh` dans votre dossier personnel pour relancer Chrome s'il crash :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# kiosk-waiter.sh
|
||||||
|
|
||||||
|
URL="https://votre-url-quizz.com"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
cage -- google-chrome-stable \
|
||||||
|
--kiosk \
|
||||||
|
--no-first-run \
|
||||||
|
--password-store=basic \
|
||||||
|
--ozone-platform=wayland \
|
||||||
|
--autoplay-policy=no-user-gesture-required \
|
||||||
|
--disable-component-update \
|
||||||
|
"$URL"
|
||||||
|
|
||||||
|
echo "Chrome s'est arrêté. Relancement dans 2 secondes..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
*N'oubliez pas : `chmod +x ~/kiosk-waiter.sh*`
|
||||||
|
|
||||||
|
## 4. Configuration Zsh (`~/.zlogin`)
|
||||||
|
|
||||||
|
Ajouter ces lignes à la fin de votre fichier `~/.zlogin` pour déclencher l'affichage uniquement sur le port HDMI physique (TTY1) :
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
# Empêcher la mise en veille de l'écran
|
||||||
|
setterm --blank 0 --powersave off --powerdown 0
|
||||||
|
|
||||||
|
if [[ -z "$DISPLAY" && "$XDG_VTNR" -eq 1 ]]; then
|
||||||
|
export MOZ_ENABLE_WAYLAND=1
|
||||||
|
export XDG_SESSION_TYPE=wayland
|
||||||
|
|
||||||
|
# Lancement du script de monitoring
|
||||||
|
exec ~/kiosk-waiter.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Debug et Commandes utiles
|
||||||
|
|
||||||
|
* **Relancer le navigateur à distance (SSH) :**
|
||||||
|
`pkill -u $USER cage` (Le script de boucle le relancera instantanément).
|
||||||
|
* **Vérifier les logs :**
|
||||||
|
`journalctl -u getty@tty1.service`
|
||||||
|
* **Forcer l'arrêt :**
|
||||||
|
Supprimer temporairement l'appel dans `~/.zlogin` ou tuer le script `kiosk-waiter.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Note : Si vous utilisez Podman pour le reste du projet (Vulture), ce setup "Bare Metal" pour l'affichage garantit une latence minimale pour les animations du tableau de score.*
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
// Import necessary modules
|
|
||||||
const mqtt = require('mqtt');
|
|
||||||
|
|
||||||
// MQTT broker configuration
|
|
||||||
const brokerUrl = 'mqtt://localhost'; // Broker URL (change if needed)
|
|
||||||
const options = {
|
|
||||||
clientId: 'test_buzzer_manager',
|
|
||||||
clean: true
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up MQTT client
|
|
||||||
const client = mqtt.connect(brokerUrl, options);
|
|
||||||
|
|
||||||
// Variables for tracking test results
|
|
||||||
let testResults = {
|
|
||||||
buzzerActivity: false,
|
|
||||||
confirmationReceived: false,
|
|
||||||
statusBlocked: false,
|
|
||||||
statusUnblocked: false,
|
|
||||||
tiltAddConfirmed: false,
|
|
||||||
tiltRemoveConfirmed: false,
|
|
||||||
tiltIgnored: false,
|
|
||||||
unlockConfirmation: false,
|
|
||||||
tiltUpdateAdd: false,
|
|
||||||
tiltUpdateRemove: false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Subscribe to topics to capture the responses from the buzzer manager
|
|
||||||
client.on('connect', () => {
|
|
||||||
console.log('[INFO] Connected to MQTT broker for testing');
|
|
||||||
|
|
||||||
// Subscribe to all topics related to the buzzer manager
|
|
||||||
client.subscribe('vulture/buzzer/#', (err) => {
|
|
||||||
if (err) console.error('[ERROR] Failed to subscribe to topics for testing');
|
|
||||||
else console.log('[INFO] Subscribed to topics successfully');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Run the test sequence after a short delay
|
|
||||||
setTimeout(runTestSequence, 500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Capture and process incoming MQTT messages
|
|
||||||
client.on('message', (topic, message) => {
|
|
||||||
const payload = JSON.parse(message.toString());
|
|
||||||
console.log(`[INFO] Message received on ${topic}: ${message.toString()}`);
|
|
||||||
|
|
||||||
// Track the test results based on the topics and payloads
|
|
||||||
if (topic.startsWith('vulture/buzzer/activity') && payload.buzzer_id === 1) {
|
|
||||||
testResults.buzzerActivity = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic.startsWith(`vulture/buzzer/confirmation/1`) && payload.status === "received") {
|
|
||||||
testResults.confirmationReceived = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic === 'vulture/buzzer/status' && payload.status === "blocked") {
|
|
||||||
testResults.statusBlocked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic === 'vulture/buzzer/status' && payload.status === "unblocked") {
|
|
||||||
testResults.statusUnblocked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic.startsWith(`vulture/buzzer/tilt/confirmation/2`) && payload.status === "received" && payload.action === "add") {
|
|
||||||
testResults.tiltAddConfirmed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic.startsWith(`vulture/buzzer/tilt/confirmation/2`) && payload.status === "received" && payload.action === "remove") {
|
|
||||||
testResults.tiltRemoveConfirmed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic === `vulture/buzzer/tilt/ignored/2` && payload.status === "tilt_ignored") {
|
|
||||||
testResults.tiltIgnored = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic === 'vulture/buzzer/status' && payload.status === "tilt_update") {
|
|
||||||
// Check for tilt update with added buzzer
|
|
||||||
if (payload.tilt_buzzers.includes(2) && payload.message.includes("added")) {
|
|
||||||
testResults.tiltUpdateAdd = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for tilt update with removed buzzer
|
|
||||||
if (!payload.tilt_buzzers.includes(2) && payload.message.includes("removed")) {
|
|
||||||
testResults.tiltUpdateRemove = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic === 'vulture/buzzer/unlock/confirmation' && payload.status === "received") {
|
|
||||||
testResults.unlockConfirmation = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Function to run the complete test sequence
|
|
||||||
function runTestSequence() {
|
|
||||||
console.log('[INFO] Starting test sequence...');
|
|
||||||
|
|
||||||
// 1. Simulate a buzzer press (buzzer 1, color red)
|
|
||||||
console.log('[TEST] Simulating buzzer press (ID 1, color #FF0000)...');
|
|
||||||
client.publish('vulture/buzzer/pressed/1', JSON.stringify({
|
|
||||||
buzzer_id: 1,
|
|
||||||
color: "#FF0000"
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 2. Simulate a second buzzer press (buzzer 2, color blue) to check blocking
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('[TEST] Simulating second buzzer press (ID 2, color #0000FF)...');
|
|
||||||
client.publish('vulture/buzzer/pressed/2', JSON.stringify({
|
|
||||||
buzzer_id: 2,
|
|
||||||
color: "#0000FF"
|
|
||||||
}));
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// 3. Simulate adding a buzzer to tilt mode (buzzer 2)
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('[TEST] Adding buzzer ID 2 to tilt mode...');
|
|
||||||
client.publish('vulture/buzzer/tilt', JSON.stringify({
|
|
||||||
buzzer_id: 2,
|
|
||||||
status: "add"
|
|
||||||
}));
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
// 4. Simulate pressing a buzzer in tilt mode (should be ignored)
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('[TEST] Simulating tilt buzzer press (ID 2, color #0000FF)...');
|
|
||||||
client.publish('vulture/buzzer/pressed/2', JSON.stringify({
|
|
||||||
buzzer_id: 2,
|
|
||||||
color: "#0000FF"
|
|
||||||
}));
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
// 5. Remove tilt mode from buzzer 2
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('[TEST] Removing tilt mode for buzzer ID 2...');
|
|
||||||
client.publish('vulture/buzzer/tilt', JSON.stringify({
|
|
||||||
buzzer_id: 2,
|
|
||||||
status: "remove"
|
|
||||||
}));
|
|
||||||
}, 2500);
|
|
||||||
|
|
||||||
// 6. Unlock buzzers to reset state
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('[TEST] Unlocking buzzers...');
|
|
||||||
client.publish('vulture/buzzer/unlock', '{}');
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
// 7. Display results
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('[INFO] Test sequence complete. Results:');
|
|
||||||
console.log(`1. Buzzer activity detected for buzzer 1: ${testResults.buzzerActivity ? 'PASSED' : 'FAILED'}`);
|
|
||||||
console.log(`2. Confirmation received for buzzer 1: ${testResults.confirmationReceived ? 'PASSED' : 'FAILED'}`);
|
|
||||||
console.log(`3. Buzzer 1 status set to "blocked": ${testResults.statusBlocked ? 'PASSED' : 'FAILED'}`);
|
|
||||||
console.log(`4. Buzzer status set to "unblocked": ${testResults.statusUnblocked ? 'PASSED' : 'FAILED'}`);
|
|
||||||
console.log(`5. Tilt mode add confirmed for buzzer 2: ${testResults.tiltAddConfirmed ? 'PASSED' : 'FAILED'}`);
|
|
||||||
console.log(`6. Tilted buzzer press ignored: ${testResults.tiltIgnored ? 'PASSED' : 'FAILED'}`);
|
|
||||||
console.log(`7. Tilt status update sent (add): ${testResults.tiltUpdateAdd ? 'PASSED' : 'FAILED'}`);
|
|
||||||
console.log(`8. Tilt mode remove confirmed for buzzer 2: ${testResults.tiltRemoveConfirmed ? 'PASSED' : 'FAILED'}`);
|
|
||||||
console.log(`9. Tilt status update sent (remove): ${testResults.tiltUpdateRemove ? 'PASSED' : 'FAILED'}`);
|
|
||||||
console.log(`10. Unlock confirmation received: ${testResults.unlockConfirmation ? 'PASSED' : 'FAILED'}`);
|
|
||||||
client.end(); // End the MQTT connection
|
|
||||||
}, 4000);
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ const { mqttHost, hosts: { buzzers: { IP: buzzerIPs, MQTTconfig: { mqttTopic } }
|
|||||||
const client = mqtt.connect(mqttHost);
|
const client = mqtt.connect(mqttHost);
|
||||||
|
|
||||||
client.on('connect', () => {
|
client.on('connect', () => {
|
||||||
console.log(`Connecté au broker MQTT à ${mqttHost}`);
|
console.log(`[INFO] Connecté au broker MQTT à ${mqttHost}`);
|
||||||
|
|
||||||
// Fonction pour pinger les buzzers et publier l'état
|
// Fonction pour pinger les buzzers et publier l'état
|
||||||
const pingAndPublish = async () => {
|
const pingAndPublish = async () => {
|
||||||
@@ -24,9 +24,8 @@ client.on('connect', () => {
|
|||||||
|
|
||||||
// Publication du statut dans le topic MQTT
|
// Publication du statut dans le topic MQTT
|
||||||
client.publish(`${mqttTopic}`, JSON.stringify({ buzzer: buzzerName, ip, status }));
|
client.publish(`${mqttTopic}`, JSON.stringify({ buzzer: buzzerName, ip, status }));
|
||||||
console.log(`Ping ${buzzerName} (${ip}) - Status: ${status}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Erreur avec le buzzer ${buzzerName} (${ip}):`, error.message);
|
console.error(`[ERREUR] Erreur avec le buzzer ${buzzerName} (${ip}):`, error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -36,5 +35,5 @@ client.on('connect', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on('error', (error) => {
|
client.on('error', (error) => {
|
||||||
console.error('Erreur de connexion au broker MQTT:', error.message);
|
console.error('[ERREUR] Erreur de connexion au broker MQTT:', error.message);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"score": {
|
"score": {
|
||||||
"MQTTconfig": {
|
"MQTTconfig": {
|
||||||
"mqttScoreTopic": "game/score",
|
"mqttScoreTopic": "game/score",
|
||||||
"mqttScoreChangeTopic": "game/score/update"
|
"mqttScoreChangeTopic": "game/score/update",
|
||||||
|
"mqttScoreRequestTopic": "game/score/request"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"quizzcollector": {
|
"quizzcollector": {
|
||||||
@@ -12,6 +13,18 @@
|
|||||||
"mqttQuizzCollectorListTopic": "game/quizz-collector/list",
|
"mqttQuizzCollectorListTopic": "game/quizz-collector/list",
|
||||||
"mqttQuizzCollectorCmdTopic": "game/quizz-collector/cmd"
|
"mqttQuizzCollectorCmdTopic": "game/quizz-collector/cmd"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"MQTTconfig": {
|
||||||
|
"mqttSessionRequestTopic": "game/session/config/request",
|
||||||
|
"mqttSessionGetTopic": "game/session/config/get",
|
||||||
|
"mqttSessionUpdateTopic": "game/session/config/update",
|
||||||
|
"mqttSessionListTopic": "game/session/list/request",
|
||||||
|
"mqttSessionListResponseTopic": "game/session/list/response",
|
||||||
|
"mqttSessionCreateTopic": "game/session/create",
|
||||||
|
"mqttSessionDeleteTopic": "game/session/delete"
|
||||||
|
},
|
||||||
|
"httpPort": 3001
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hosts": {
|
"hosts": {
|
||||||
@@ -27,4 +40,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,14 +33,14 @@ fs.access(filePath, fs.constants.F_OK, (err) => {
|
|||||||
// Le fichier existe, on le lit et on le parse en JSON
|
// Le fichier existe, on le lit et on le parse en JSON
|
||||||
fs.readFile(filePath, 'utf8', (err, data) => {
|
fs.readFile(filePath, 'utf8', (err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Erreur de lecture du fichier :", err);
|
console.error("[ERREUR] Erreur de lecture du fichier :", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
global.jsonData = JSON.parse(data);
|
global.jsonData = JSON.parse(data);
|
||||||
console.log("Propriétés importées depuis le fichier JSON :");
|
console.log("[INFO] Propriétés importées depuis le fichier JSON :");
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
console.error("Erreur de parsing JSON :", parseErr);
|
console.error("[ERREUR] Erreur de parsing JSON :", parseErr);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -82,10 +82,10 @@ fs.access(filePath, fs.constants.F_OK, (err) => {
|
|||||||
|
|
||||||
fs.writeFile(newFilePath, JSON.stringify(initialContent, null, 2), (err) => {
|
fs.writeFile(newFilePath, JSON.stringify(initialContent, null, 2), (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Erreur de création du fichier :", err);
|
console.error("[ERREUR] Erreur de création du fichier :", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`Fichier JSON créé avec succès : ${newFilePath}`);
|
console.log(`[INFO] Fichier JSON créé avec succès : ${newFilePath}`);
|
||||||
// Mettre à jour ScoreFile et filePath
|
// Mettre à jour ScoreFile et filePath
|
||||||
|
|
||||||
// Charger les données initiales si nécessaire
|
// Charger les données initiales si nécessaire
|
||||||
@@ -99,14 +99,14 @@ fs.access(filePath, fs.constants.F_OK, (err) => {
|
|||||||
function updateTeamTotalScore(teamColor, points) {
|
function updateTeamTotalScore(teamColor, points) {
|
||||||
fs.readFile(filePath, 'utf8', (err, data) => {
|
fs.readFile(filePath, 'utf8', (err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Erreur de lecture du fichier :", err);
|
console.error("[ERREUR] Erreur de lecture du fichier :", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const jsonData = JSON.parse(data);
|
const jsonData = JSON.parse(data);
|
||||||
// Vérifier si l'équipe existe
|
// Vérifier si l'équipe existe
|
||||||
if (!jsonData.TEAM.hasOwnProperty(teamColor)) {
|
if (!jsonData.TEAM.hasOwnProperty(teamColor)) {
|
||||||
console.error(`L'équipe ${teamColor} n'existe pas.`);
|
console.error(`[ERREUR] L'équipe ${teamColor} n'existe pas.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,18 +114,22 @@ function updateTeamTotalScore(teamColor, points) {
|
|||||||
|
|
||||||
// Mettre à jour le score
|
// Mettre à jour le score
|
||||||
jsonData.TEAM[teamColor].TotalScore += points;
|
jsonData.TEAM[teamColor].TotalScore += points;
|
||||||
console.log(`Le score total pour l'équipe ${teamColor} est de ${jsonData.TEAM[teamColor].TotalScore} points !`)
|
|
||||||
|
// Update global state
|
||||||
|
global.jsonData = jsonData;
|
||||||
|
|
||||||
|
console.log(`[INFO] Le score total pour l'équipe ${teamColor} est de ${jsonData.TEAM[teamColor].TotalScore} points !`)
|
||||||
// Enregistrer les modifications dans le fichier
|
// Enregistrer les modifications dans le fichier
|
||||||
client.publish(mqttScoreTopic, JSON.stringify(jsonData));
|
client.publish(mqttScoreTopic, JSON.stringify(jsonData));
|
||||||
fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), (err) => {
|
fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Erreur lors de l'écriture du fichier :", err);
|
console.error("[ERREUR] Erreur lors de l'écriture du fichier :", err);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Le score total de l'équipe ${teamColor} a été mis à jour avec succès dans le fichier json !`);
|
console.log(`[INFO] Le score total de l'équipe ${teamColor} a été mis à jour avec succès dans le fichier json !`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
console.error("Erreur de parsing JSON :", parseErr);
|
console.error("[ERREUR] Erreur de parsing JSON :", parseErr);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -136,26 +140,45 @@ const configPath = path.join(__dirname, '../config/configuration.json');
|
|||||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
|
||||||
// Extraction des informations de config
|
// Extraction des informations de config
|
||||||
const { mqttHost, services: { score: { MQTTconfig: { mqttScoreTopic, mqttScoreChangeTopic } } } } = config;
|
const { mqttHost, services: { score: { MQTTconfig: { mqttScoreTopic, mqttScoreChangeTopic, mqttScoreRequestTopic } } } } = config;
|
||||||
console.log("DEBUG: Config loaded from:", configPath);
|
console.log("------------------------------------------------------------------------------");
|
||||||
console.log("DEBUG: MQTT Host:", mqttHost);
|
console.log("[CONFIG] Configuration chargée depuis :", configPath);
|
||||||
console.log("DEBUG: Topics:", mqttScoreTopic, mqttScoreChangeTopic);
|
console.log("[CONFIG] Hôte MQTT :", mqttHost);
|
||||||
|
console.log("[CONFIG] Topics chargés :", mqttScoreTopic, mqttScoreChangeTopic, mqttScoreRequestTopic);
|
||||||
|
console.log("------------------------------------------------------------------------------");
|
||||||
|
|
||||||
// Connexion au broker MQTT
|
// Connexion au broker MQTT
|
||||||
const client = mqtt.connect(mqttHost);
|
const client = mqtt.connect(mqttHost);
|
||||||
|
|
||||||
client.on('connect', () => {
|
client.on('connect', () => {
|
||||||
console.log(`Connecté au broker MQTT à ${mqttHost}`);
|
console.log(`[INFO] Connecté au broker MQTT à ${mqttHost}`);
|
||||||
|
|
||||||
client.subscribe(mqttScoreChangeTopic, (err) => {
|
client.subscribe(mqttScoreChangeTopic, (err) => {
|
||||||
if (err) console.error('[ERROR] impossible de souscrire au topic de gestion du score total');
|
if (err) console.error('[ERREUR] impossible de souscrire au topic de gestion du score total');
|
||||||
else console.log(`[INFO] Souscription réalisée avec succès au topic ${mqttScoreChangeTopic}]`);
|
else console.log(`[INFO] Souscription réalisée avec succès au topic ${mqttScoreChangeTopic}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.subscribe(mqttScoreRequestTopic, (err) => {
|
||||||
|
if (err) console.error('[ERREUR] impossible de souscrire au topic de demande de score');
|
||||||
|
else console.log(`[INFO] Souscription réalisée avec succès au topic ${mqttScoreRequestTopic}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gestion des messages entrants
|
// Gestion des messages entrants
|
||||||
client.on('message', (topic, message) => {
|
client.on('message', (topic, message) => {
|
||||||
|
// Gestion de la demande de score (REFRESH)
|
||||||
|
if (topic === mqttScoreRequestTopic) {
|
||||||
|
console.log(`[INFO] Demande de rafraîchissement des scores reçue sur ${topic}`);
|
||||||
|
if (global.jsonData) {
|
||||||
|
client.publish(mqttScoreTopic, JSON.stringify(global.jsonData));
|
||||||
|
console.log(`[INFO] Scores envoyés sur ${mqttScoreTopic}`);
|
||||||
|
} else {
|
||||||
|
console.warn("[INFO] Aucune donnée de score disponible pour le rafraîchissement");
|
||||||
|
}
|
||||||
|
return; // Fin du traitement pour ce message
|
||||||
|
}
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
let process;
|
let process;
|
||||||
let Team;
|
let Team;
|
||||||
@@ -167,7 +190,7 @@ client.on('message', (topic, message) => {
|
|||||||
// Analyse du message reçu
|
// Analyse du message reçu
|
||||||
payload = JSON.parse(message.toString());
|
payload = JSON.parse(message.toString());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[ERROR] Invalid JSON message received on topic ${topic}: ${message.toString()}`);
|
console.error(`[ERREUR] Invalid JSON message received on topic ${topic}: ${message.toString()}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Vérifie que le payload est bien un objet
|
// Vérifie que le payload est bien un objet
|
||||||
@@ -202,7 +225,7 @@ client.on('message', (topic, message) => {
|
|||||||
if (!isNaN(change)) {
|
if (!isNaN(change)) {
|
||||||
updateTeamTotalScore(Team, change);
|
updateTeamTotalScore(Team, change);
|
||||||
} else {
|
} else {
|
||||||
console.error(`Action invalide : ${Action}`);
|
console.error(`[ERREUR] Action invalide : ${Action}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +236,7 @@ client.on('message', (topic, message) => {
|
|||||||
function updateTeamScoreAbsolute(teamColor, totalScore, roundScore) {
|
function updateTeamScoreAbsolute(teamColor, totalScore, roundScore) {
|
||||||
fs.readFile(filePath, 'utf8', (err, data) => {
|
fs.readFile(filePath, 'utf8', (err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Erreur de lecture du fichier :", err);
|
console.error("[ERREUR] Erreur de lecture du fichier :", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -232,26 +255,26 @@ function updateTeamScoreAbsolute(teamColor, totalScore, roundScore) {
|
|||||||
|
|
||||||
console.log(`Mise à jour absolue pour ${teamColor} -> Total: ${jsonData.TEAM[teamColor].TotalScore}, Round: ${jsonData.TEAM[teamColor].RoundScore}`);
|
console.log(`Mise à jour absolue pour ${teamColor} -> Total: ${jsonData.TEAM[teamColor].TotalScore}, Round: ${jsonData.TEAM[teamColor].RoundScore}`);
|
||||||
|
|
||||||
|
// Update global state
|
||||||
|
global.jsonData = jsonData;
|
||||||
|
|
||||||
client.publish(mqttScoreTopic, JSON.stringify(jsonData));
|
client.publish(mqttScoreTopic, JSON.stringify(jsonData));
|
||||||
fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), (err) => {
|
fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), (err) => {
|
||||||
if (err) console.error("Erreur d'écriture :", err);
|
if (err) console.error("[ERREUR] Erreur d'écriture :", err);
|
||||||
});
|
});
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
console.error("Erreur JSON :", parseErr);
|
console.error("[ERREUR] Erreur JSON :", parseErr);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
console.log("Boucle en arrière-plan");
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // Pause de 2 secondes
|
await new Promise((resolve) => setTimeout(resolve, 2000)); // Pause de 2 secondes
|
||||||
//client.publish(mqttScoreTopic, JSON.stringify(global.jsonData));
|
//client.publish(mqttScoreTopic, JSON.stringify(global.jsonData));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
client.on('error', (error) => {
|
client.on('error', (error) => {
|
||||||
console.error('Erreur de connexion au broker MQTT:', error.message);
|
console.error("[ERREUR] Erreur de connexion au broker MQTT:", error.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
290
VNode/services/game/session-manager.js
Normal file
290
VNode/services/game/session-manager.js
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const mqtt = require('mqtt');
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
|
// Lecture du fichier de configuration
|
||||||
|
const configPath = path.join(__dirname, '../config/configuration.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
|
||||||
|
// Extraction des informations de config
|
||||||
|
const {
|
||||||
|
mqttHost,
|
||||||
|
services: {
|
||||||
|
session: {
|
||||||
|
MQTTconfig: {
|
||||||
|
mqttSessionRequestTopic,
|
||||||
|
mqttSessionGetTopic,
|
||||||
|
mqttSessionUpdateTopic,
|
||||||
|
mqttSessionListTopic,
|
||||||
|
mqttSessionListResponseTopic,
|
||||||
|
mqttSessionCreateTopic,
|
||||||
|
mqttSessionDeleteTopic
|
||||||
|
},
|
||||||
|
httpPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const PORT = httpPort || 3000;
|
||||||
|
const quizzDir = path.join(__dirname, 'quizz');
|
||||||
|
|
||||||
|
// Configurer Express
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
|
// Configuration de Multer pour l'upload
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: function (req, file, cb) {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
let subfolder = 'assets';
|
||||||
|
|
||||||
|
// Organiser par type (envoyé par le frontend dans req.body.type)
|
||||||
|
if (req.body.type === 'video') subfolder = 'assets/videos';
|
||||||
|
else if (req.body.type === 'audio') subfolder = 'assets/audios';
|
||||||
|
else if (req.body.type === 'picture') subfolder = 'assets/pictures';
|
||||||
|
|
||||||
|
const uploadPath = path.join(quizzDir, sessionId, subfolder);
|
||||||
|
|
||||||
|
// Créer le dossier s'il n'existe pas
|
||||||
|
fs.mkdirSync(uploadPath, { recursive: true });
|
||||||
|
cb(null, uploadPath);
|
||||||
|
},
|
||||||
|
filename: function (req, file, cb) {
|
||||||
|
// Utiliser l'ID de la question comme nom de fichier si disponible
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
let filename = 'file-' + Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
|
||||||
|
if (req.body.questionId) {
|
||||||
|
filename = req.body.questionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, filename + ext);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({ storage: storage });
|
||||||
|
|
||||||
|
// Route d'upload
|
||||||
|
app.post('/upload/:sessionId', upload.single('file'), (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).send('No file uploaded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer le chemin relatif pour l'URL
|
||||||
|
// req.file.path est le chemin absolu sur le disque
|
||||||
|
// On veut le chemin relatif à partir du dossier de session
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
const sessionDir = path.join(quizzDir, sessionId);
|
||||||
|
|
||||||
|
// path.relative(from, to) -> donne le chemin relatif
|
||||||
|
let relativeId = path.relative(sessionDir, req.file.path);
|
||||||
|
// Remplacer les backslashes par des slashs pour les URLs web
|
||||||
|
relativeId = relativeId.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// Ajouter le slash initial
|
||||||
|
const relativePath = `/${relativeId}`;
|
||||||
|
|
||||||
|
res.json({ path: relativePath, fullPath: req.file.path });
|
||||||
|
});
|
||||||
|
|
||||||
|
// App.use pour servir les fichiers statiques
|
||||||
|
app.use('/quizz', express.static(quizzDir));
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[HTTP] Serveur d'upload démarré sur le port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Connexion au broker MQTT
|
||||||
|
const client = mqtt.connect(mqttHost);
|
||||||
|
|
||||||
|
console.log("------------------------------------------------------------------------------");
|
||||||
|
console.log("[CONFIG] Session Manager chargé (Multi-Session + Upload)");
|
||||||
|
console.log("[CONFIG] Hôte MQTT :", mqttHost);
|
||||||
|
console.log("[CONFIG] Port HTTP :", PORT);
|
||||||
|
console.log("[CONFIG] Dossier Quizz :", quizzDir);
|
||||||
|
console.log("------------------------------------------------------------------------------");
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
console.log(`[INFO] Connecté au broker MQTT à ${mqttHost}`);
|
||||||
|
|
||||||
|
client.subscribe(mqttSessionRequestTopic);
|
||||||
|
client.subscribe(mqttSessionUpdateTopic);
|
||||||
|
client.subscribe(mqttSessionListTopic);
|
||||||
|
client.subscribe(mqttSessionCreateTopic);
|
||||||
|
client.subscribe(mqttSessionDeleteTopic);
|
||||||
|
|
||||||
|
console.log(`[INFO] Abonné aux topics session`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('message', (topic, message) => {
|
||||||
|
if (topic === mqttSessionListTopic) {
|
||||||
|
console.log(`[INFO] Demande de liste de sessions`);
|
||||||
|
sendSessionList();
|
||||||
|
} else if (topic === mqttSessionRequestTopic) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(message.toString());
|
||||||
|
console.log(`[INFO] Demande de configuration pour session: ${payload.SessionId}`);
|
||||||
|
if (payload.SessionId) {
|
||||||
|
sendSessionConfiguration(payload.SessionId);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error("Erreur payload request", e); }
|
||||||
|
|
||||||
|
} else if (topic === mqttSessionUpdateTopic) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(message.toString());
|
||||||
|
console.log(`[INFO] Mise à jour configuration pour session: ${payload.SessionId}`);
|
||||||
|
if (payload.SessionId && payload.Config) {
|
||||||
|
saveSessionConfiguration(payload.SessionId, payload.Config);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ERREUR] Impossible de parser la mise à jour', e);
|
||||||
|
}
|
||||||
|
} else if (topic === mqttSessionCreateTopic) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(message.toString());
|
||||||
|
console.log(`[INFO] Demande de création de session: ${payload.SessionName}`);
|
||||||
|
if (payload.SessionName) {
|
||||||
|
createSession(payload.SessionName);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ERREUR] Impossible de parser la demande de création', e);
|
||||||
|
}
|
||||||
|
} else if (topic === mqttSessionDeleteTopic) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(message.toString());
|
||||||
|
console.log(`[INFO] Demande de suppression de session: ${payload.SessionId}`);
|
||||||
|
if (payload.SessionId) {
|
||||||
|
deleteSession(payload.SessionId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ERREUR] Impossible de parser la demande de suppression', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendSessionList() {
|
||||||
|
fs.readdir(quizzDir, { withFileTypes: true }, (err, entries) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[ERREUR] Lecture dossier quizz', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = [];
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const configPath = path.join(quizzDir, entry.name, 'session-configuration.json');
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
try {
|
||||||
|
const sessConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
sessions.push({
|
||||||
|
id: entry.name,
|
||||||
|
title: sessConfig.PackTitle || entry.name
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
sessions.push({ id: entry.name, title: entry.name + " (Erreur Config)" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.publish(mqttSessionListResponseTopic, JSON.stringify(sessions));
|
||||||
|
console.log(`[INFO] Liste envoyée : ${sessions.length} sessions`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSessionConfiguration(sessionId) {
|
||||||
|
const sessionFilePath = path.join(quizzDir, sessionId, 'session-configuration.json');
|
||||||
|
fs.readFile(sessionFilePath, 'utf8', (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[ERREUR] Impossible de lire le fichier de session', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.publish(mqttSessionGetTopic, data);
|
||||||
|
console.log(`[INFO] Configuration envoyée pour ${sessionId}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSessionConfiguration(sessionId, newConfig) {
|
||||||
|
const sessionFilePath = path.join(quizzDir, sessionId, 'session-configuration.json');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!newConfig.Questions || !Array.isArray(newConfig.Questions)) {
|
||||||
|
console.error('[ERREUR] Configuration invalide');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.stringify(newConfig, null, 2);
|
||||||
|
fs.writeFile(sessionFilePath, data, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[ERREUR] Impossible d\'écrire le fichier de session', err);
|
||||||
|
} else {
|
||||||
|
console.log(`[INFO] Session ${sessionId} mise à jour avec succès`);
|
||||||
|
// Confirmer la sauvegarde en renvoyant la config
|
||||||
|
client.publish(mqttSessionGetTopic, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession(sessionName) {
|
||||||
|
// Nettoyer le nom pour le dossier
|
||||||
|
const safeName = sessionName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||||
|
const newSessionPath = path.join(quizzDir, safeName);
|
||||||
|
|
||||||
|
if (fs.existsSync(newSessionPath)) {
|
||||||
|
console.error(`[ERREUR] La session ${safeName} existe déjà`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer le dossier
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(newSessionPath, { recursive: true });
|
||||||
|
|
||||||
|
// Créer la structure de base
|
||||||
|
const defaultConfig = {
|
||||||
|
PackId: "",
|
||||||
|
PackTitle: sessionName,
|
||||||
|
Questions: []
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(newSessionPath, 'session-configuration.json'),
|
||||||
|
JSON.stringify(defaultConfig, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[INFO] Nouvelle session créée: ${safeName}`);
|
||||||
|
|
||||||
|
// Renvoyer la liste mise à jour
|
||||||
|
sendSessionList();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ERREUR] Impossible de créer la session ${safeName}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSession(sessionId) {
|
||||||
|
const sessionPath = path.join(quizzDir, sessionId);
|
||||||
|
|
||||||
|
if (!fs.existsSync(sessionPath)) {
|
||||||
|
console.error(`[ERREUR] La session ${sessionId} n'existe pas`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.rmSync(sessionPath, { recursive: true, force: true });
|
||||||
|
console.log(`[INFO] Session supprimée: ${sessionId}`);
|
||||||
|
|
||||||
|
// Renvoyer la liste mise à jour
|
||||||
|
sendSessionList();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ERREUR] Impossible de supprimer la session ${sessionId}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.on('error', (error) => {
|
||||||
|
console.error('[ERREUR] Erreur de connexion au broker MQTT:', error.message);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user