Compare commits

...

3 Commits

Author SHA1 Message Date
31649435a6 (update) : gestion des sessions et prévisualisation des médias
- Ajout bouton création de session avec dialog

- Ajout bouton suppression de session avec confirmation

- Ajout preview plein écran pour images, vidéos et audio

- Remplacement v-file-input par v-btn stylé pour upload

- Preview vidéo à 1/3 de la durée pour éviter écran noir

- Améliorations de style: dialogs arrondis, champs alignés
2026-02-05 21:53:34 +01:00
0f0f1ffe33 (update) : implémentation création et suppression de sessions)
- Ajout de createSession() pour créer un dossier session avec config par défaut

- Ajout de deleteSession() pour supprimer un dossier session récursivement

- Abonnement aux topics game/session/create et game/session/delete

- Rafraîchissement automatique de la liste après création/suppression
2026-02-05 21:52:26 +01:00
1c2c8dfcbf (update) : ajout des topics MQTT pour création et suppression de session 2026-02-05 21:51:17 +01:00
3 changed files with 912 additions and 0 deletions

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

View File

@@ -13,6 +13,18 @@
"mqttQuizzCollectorListTopic": "game/quizz-collector/list",
"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": {

View 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);
});