sépare la vitrine de l'application : vraie coquille, barre par rôle, chat
Une fois connecté, plus aucun lien vitrine : nouvelle layouts/app (barre
du haut avec menu avatar, barre latérale par rôle, bulle de chat
Messenger, barre d'onglets mobile). Les 10 entrées de navigation
demandées sont construites en vrai plutôt qu'en renvoi "bientôt
disponible" : Ma classe, Emploi du temps, Mes devoirs, Mes cours, Mes
notes côté élève ; Mes classes, Emploi du temps, Séances en direct,
Bibliothèque, Résultats (avec Observations, la dernière fonction
fictive du tableau de bord démo) côté prof.
Chat élève <-> prof de sa classe, jamais entre élèves. Mon profil,
Paramètres (thème, taille du texte), Signaler un problème, Mes
raccourcis. /eleve vidée des faux contenus ("À faire", "Ma progression",
"Mes QCM") restés avant le chantier "vrai cockpit".
Bug trouvé en testant en réel sur le serveur (pas en local) : le
compteur de messages non lus du chat ne redescendait jamais à zéro
(FilConversation sans lu_par_prof_a/lu_par_eleve_a dans son fillable) —
corrigé avec un test de non-régression.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-13 18:21:28 +02:00
|
|
|
|
@extends('layouts.app')
|
2026-10-12 12:35:35 +02:00
|
|
|
|
|
|
|
|
|
|
@section('titre', 'Visio · '.$seance->classe->nom)
|
|
|
|
|
|
|
|
|
|
|
|
{{-- Étape 15 : reprend design/resources/views/visio.blade.php (vue prof) ; la
|
|
|
|
|
|
grille factice est remplacée par la vraie visio (Jitsi, IFrame API). --}}
|
|
|
|
|
|
@section('contenu')
|
|
|
|
|
|
<main style="background:#f8f9fa">
|
|
|
|
|
|
<section style="padding:40px 20px 8px"><div style="max-width:1280px;margin:0 auto">
|
|
|
|
|
|
<p style="margin:0;display:inline-flex;align-items:center;gap:8px;border:1px solid #b8c1cf;border-radius:999px;background:#fff;padding:4px 12px;font-size:14px;font-weight:700;color:#1d3fa8">Visio de classe</p>
|
|
|
|
|
|
<h1 style="margin:14px 0 0;font-size:clamp(30px,4vw,44px);line-height:1.1;font-weight:800;letter-spacing:-0.02em;color:#0b1220">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</h1>
|
|
|
|
|
|
<p style="margin:12px 0 0;max-width:720px;font-size:18px;line-height:1.6;font-weight:500;color:#3c4043">Tous les élèves arrivent en sourdine. Ils lèvent la main, vous donnez la parole, un à la fois.</p>
|
|
|
|
|
|
</div></section>
|
|
|
|
|
|
|
|
|
|
|
|
<section style="padding:20px 20px 72px"><div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]" style="max-width:1280px;margin:0 auto">
|
|
|
|
|
|
<div style="background:#fff;border:1px solid #e3e5e8;border-radius:20px;overflow:hidden;min-width:0">
|
|
|
|
|
|
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #e3e5e8">
|
|
|
|
|
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
|
|
|
|
@if ($jeton)
|
|
|
|
|
|
<span style="display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border-radius:999px;background:#fce8e6;color:#b3261e;font-size:13px;font-weight:800"><span style="width:8px;height:8px;border-radius:50%;background:#d93025"></span>En direct · <span id="v-chrono" style="font-family:'IBM Plex Mono',monospace">00:00:00</span></span>
|
|
|
|
|
|
@else
|
|
|
|
|
|
<span style="display:inline-flex;align-items:center;height:28px;padding:0 10px;border-radius:999px;background:#eef0f3;color:#3c4043;font-size:13px;font-weight:800">Visio fermée</span>
|
|
|
|
|
|
@endif
|
|
|
|
|
|
<span style="font-size:14px;font-weight:700;color:#0b1220">{{ $seance->classe->nom }}</span><span id="v-nb" style="font-size:14px;font-weight:600;color:#5f6368"></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
|
|
|
|
|
@if ($jeton)
|
|
|
|
|
|
<button type="button" id="v-mute-all" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:1px solid #dadce0;background:#fff;color:#0b1220"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3M3 3l18 18"/></svg>Couper tous les micros</button>
|
|
|
|
|
|
<form id="v-terminer" action="{{ route('seances.visio.fermer', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:0;background:#d93025;color:#fff">Terminer la visio</button></form>
|
|
|
|
|
|
@else
|
|
|
|
|
|
<form action="{{ route('seances.visio.ouvrir', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:0;background:#0b57d0;color:#fff">Ouvrir la visio</button></form>
|
|
|
|
|
|
@endif
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
@if ($jeton)
|
|
|
|
|
|
<div id="v-scene" style="height:min(70vh,620px);background:#202124"></div>
|
|
|
|
|
|
@else
|
|
|
|
|
|
<p style="margin:0;padding:40px 20px;font-size:16px;font-weight:600;color:#3c4043">La visio n’est pas ouverte. Les élèves qui la rejoignent attendent dans la salle d’attente ; ouvrez-la pour les faire entrer.</p>
|
|
|
|
|
|
@endif
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="display:grid;gap:20px;align-content:start;min-width:0">
|
|
|
|
|
|
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-mains"><div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-mains" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Mains levées</h2><span id="v-nb-mains" style="min-width:28px;height:28px;padding:0 8px;border-radius:999px;background:#fef7e0;color:#7a4b00;display:inline-grid;place-items:center;font-size:13px;font-weight:800">0</span></div><ol id="v-mains" style="margin:0;padding:8px 12px;list-style:none"></ol><p style="margin:0;padding:0 20px 14px;font-size:13px;font-weight:600;color:#5f6368">Premier arrivé en haut.</p></section>
|
|
|
|
|
|
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-attente"><div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-attente" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Salle d’attente</h2>@unless ($jeton)<form action="{{ route('seances.visio.ouvrir', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" id="v-tout-admettre" style="border:0;background:none;color:#0b57d0;font-size:14px;font-weight:700;cursor:pointer">Tout admettre</button></form>@endunless</div><ul id="v-attente" style="margin:0;padding:8px 12px;list-style:none"></ul></section>
|
|
|
|
|
|
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-chat"><div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-chat" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Chat des élèves</h2><button type="button" id="v-chat-switch" role="switch" aria-checked="{{ $seance->visio_chat ? 'true' : 'false' }}" aria-label="Activer le chat des élèves" @disabled(! $jeton) style="position:relative;width:44px;height:26px;border-radius:999px;border:0;cursor:pointer;transition:background .2s;background:{{ $seance->visio_chat ? '#0b57d0' : '#c4c7c5' }}"><span style="position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:transform .2s;transform:translateX({{ $seance->visio_chat ? '18px' : '0' }})"></span></button></div><div id="v-chat" style="padding:16px 20px;font-size:14px;font-weight:600;color:#5f6368">{{ $seance->visio_chat ? 'Le chat est ouvert aux élèves.' : 'Le chat est désactivé.' }}</div></section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div></section>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
@if ($jeton)
|
|
|
|
|
|
<script nonce="{{ Vite::cspNonce() }}" src="https://{{ config('visio.domaine') }}/external_api.js"></script>
|
|
|
|
|
|
@endif
|
|
|
|
|
|
<script nonce="{{ Vite::cspNonce() }}">
|
|
|
|
|
|
(function () {
|
|
|
|
|
|
var URL_ETAT = @json(route('seances.visio.etat', $seance));
|
|
|
|
|
|
var URL_CHAT = @json(route('seances.visio.chat', $seance));
|
|
|
|
|
|
var URL_PAROLE = @json(route('seances.visio.parole', [$seance, '__ELEVE__']));
|
|
|
|
|
|
var CSRF = @json(csrf_token());
|
|
|
|
|
|
var $ = function (s) { return document.querySelector(s); };
|
|
|
|
|
|
var poster = function (url, donnees) {
|
|
|
|
|
|
return fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(donnees || {}) });
|
|
|
|
|
|
};
|
|
|
|
|
|
var esc = function (t) { var d = document.createElement('span'); d.textContent = t; return d.innerHTML; };
|
|
|
|
|
|
var api = null;
|
|
|
|
|
|
var BOUTONS = ['microphone', 'camera', 'desktop', 'raisehand', 'participants-pane', 'tileview', 'settings', 'fullscreen', 'hangup'];
|
|
|
|
|
|
|
|
|
|
|
|
@if ($jeton)
|
|
|
|
|
|
api = new JitsiMeetExternalAPI(@json(config('visio.domaine')), {
|
|
|
|
|
|
roomName: @json($seance->visio_salle),
|
|
|
|
|
|
jwt: @json($jeton),
|
|
|
|
|
|
parentNode: $('#v-scene'),
|
|
|
|
|
|
width: '100%', height: '100%',
|
|
|
|
|
|
configOverwrite: {
|
|
|
|
|
|
startWithAudioMuted: true, startWithVideoMuted: true, readOnlyName: true,
|
|
|
|
|
|
prejoinConfig: { enabled: false }, disableDeepLinking: true,
|
|
|
|
|
|
toolbarButtons: BOUTONS.concat(@json($seance->visio_chat) ? ['chat'] : [])
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// Seul le prof donne la parole : modération du micro dès l'arrivée.
|
|
|
|
|
|
api.addListener('videoConferenceJoined', function () {
|
|
|
|
|
|
api.executeCommand('toggleModeration', true, 'audio');
|
|
|
|
|
|
});
|
|
|
|
|
|
var compter = function () { $('#v-nb').textContent = '· ' + api.getNumberOfParticipants() + ' en ligne'; };
|
|
|
|
|
|
api.addListener('participantJoined', compter);
|
|
|
|
|
|
api.addListener('participantLeft', compter);
|
|
|
|
|
|
api.addListener('readyToClose', function () { $('#v-terminer').submit(); });
|
|
|
|
|
|
$('#v-mute-all').addEventListener('click', function () { api.executeCommand('muteEveryone', 'audio'); });
|
|
|
|
|
|
$('#v-terminer').addEventListener('submit', function () { try { api.executeCommand('endConference'); } catch (e) {} });
|
|
|
|
|
|
|
|
|
|
|
|
var debut = new Date(@json($seance->visio_ouverte_a?->toIso8601String())).getTime();
|
|
|
|
|
|
setInterval(function () {
|
|
|
|
|
|
var s = Math.max(0, Math.floor((Date.now() - debut) / 1000));
|
|
|
|
|
|
$('#v-chrono').textContent = [Math.floor(s / 3600), Math.floor(s / 60) % 60, s % 60].map(function (n) { return String(n).padStart(2, '0'); }).join(':');
|
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
|
|
|
|
|
|
$('#v-chat-switch').addEventListener('click', function () {
|
|
|
|
|
|
var actif = this.getAttribute('aria-checked') !== 'true';
|
|
|
|
|
|
poster(URL_CHAT, { actif: actif }).then(function () { rafraichir(); });
|
|
|
|
|
|
});
|
|
|
|
|
|
@endif
|
|
|
|
|
|
|
|
|
|
|
|
function rafraichir() {
|
|
|
|
|
|
fetch(URL_ETAT, { headers: { 'Accept': 'application/json' } }).then(function (r) { return r.json(); }).then(function (e) {
|
|
|
|
|
|
$('#v-nb-mains').textContent = e.mains.length;
|
|
|
|
|
|
$('#v-mains').innerHTML = e.mains.length ? e.mains.map(function (m) {
|
|
|
|
|
|
var attente = Math.max(0, Math.floor(Date.now() / 1000) - m.depuis);
|
|
|
|
|
|
return '<li style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px;border-radius:12px">'
|
|
|
|
|
|
+ '<span><strong>' + esc(m.nom) + '</strong> <span style="font-size:13px;color:#5f6368">' + (attente < 60 ? attente + ' s' : Math.floor(attente / 60) + ' min') + '</span></span>'
|
|
|
|
|
|
+ (api && m.participant_id ? '<button type="button" data-parole="' + m.eleve_id + '" data-participant="' + esc(m.participant_id) + '" style="display:inline-flex;align-items:center;height:32px;padding:0 14px;border-radius:999px;border:0;cursor:pointer;font-size:13px;font-weight:700;background:#0b57d0;color:#fff">Donner la parole</button>' : '')
|
|
|
|
|
|
+ '</li>';
|
|
|
|
|
|
}).join('') : '<li style="padding:8px;font-size:14px;color:#5f6368">Aucune main levée.</li>';
|
|
|
|
|
|
$('#v-attente').innerHTML = e.attente.length ? e.attente.map(function (a) {
|
|
|
|
|
|
return '<li style="padding:8px;font-weight:700">' + esc(a.nom) + '</li>';
|
|
|
|
|
|
}).join('') : '<li style="padding:8px;font-size:14px;color:#5f6368">Personne n’attend.</li>';
|
|
|
|
|
|
var sw = $('#v-chat-switch');
|
|
|
|
|
|
if (sw.getAttribute('aria-checked') !== String(e.chat)) {
|
|
|
|
|
|
sw.setAttribute('aria-checked', String(e.chat));
|
|
|
|
|
|
sw.style.background = e.chat ? '#0b57d0' : '#c4c7c5';
|
|
|
|
|
|
sw.firstElementChild.style.transform = e.chat ? 'translateX(18px)' : 'translateX(0)';
|
|
|
|
|
|
$('#v-chat').textContent = e.chat ? 'Le chat est ouvert aux élèves.' : 'Le chat est désactivé.';
|
|
|
|
|
|
if (api) api.executeCommand('overwriteConfig', { toolbarButtons: BOUTONS.concat(e.chat ? ['chat'] : []) });
|
|
|
|
|
|
}
|
|
|
|
|
|
}).catch(function () {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// « Donner la parole » : askToUnmute autorise le micro quand la modération
|
|
|
|
|
|
// est active (documentation de l'IFrame API), puis la main est baissée.
|
|
|
|
|
|
$('#v-mains').addEventListener('click', function (ev) {
|
|
|
|
|
|
var b = ev.target.closest('[data-parole]');
|
|
|
|
|
|
if (!b || !api) return;
|
|
|
|
|
|
api.executeCommand('askToUnmute', b.dataset.participant);
|
|
|
|
|
|
poster(URL_PAROLE.replace('__ELEVE__', b.dataset.parole)).then(rafraichir);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
rafraichir();
|
|
|
|
|
|
setInterval(rafraichir, 2000);
|
|
|
|
|
|
})();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
@endsection
|