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
<! DOCTYPE html >
< html lang = " fr " data - theme = " { { $compte->preferenceTheme () }} " data - taille = " { { $compte->preferenceTailleTexte () }} " >
< head >
< meta charset = " utf-8 " >
< meta name = " viewport " content = " width=device-width, initial-scale=1, viewport-fit=cover " >
< title >@ yield ( 'titre' ) · Syllabus </ title >
{{ Vite :: fonts () }}
@ vite ([ 'resources/css/app.css' , 'resources/js/app.js' ])
< link rel = " stylesheet " href = " { { asset('css/syllabus-theme.css') }} " >
< link rel = " stylesheet " href = " { { asset('css/app-shell.css') }} " >
@ stack ( 'head' )
</ head >
< body class = " app-corps font-display antialiased " >
< header class = " app-topbar " >
< a href = " { { $estProf ? route('mes-classes.index') : route('eleve') }} " class = " app-topbar__logo " aria - label = " Syllabus, accueil " >
< img src = " { { asset('images/logo-syllabus-icone.png') }} " alt = " " >
< span > Syllabus </ span >
</ a >
< div class = " app-topbar__avatar " >
< button type = " button " id = " app-avatar-bouton " aria - haspopup = " true " aria - expanded = " false " aria - controls = " app-avatar-menu " >
< span class = " app-topbar__avatar-cercle " aria - hidden = " true " > {{ mb_substr ( $nomAffiche , 0 , 1 ) }} </ span >
< span class = " hidden sm:inline " > {{ $nomAffiche }} </ span >
</ button >
< div id = " app-avatar-menu " class = " app-topbar__menu " hidden role = " menu " >
< a href = " { { route('profil') }} " role = " menuitem " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 1.8 " >< circle cx = " 12 " cy = " 8 " r = " 4 " />< path d = " M4 20c0-4 3.5-6 8-6s8 2 8 6 " /></ svg >
Mon profil
</ a >
< a href = " { { route('parametres') }} " role = " menuitem " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 1.8 " >< circle cx = " 12 " cy = " 12 " r = " 3 " />< path d = " M19.4 13a7.6 7.6 0 0 0 0-2l2-1.5-2-3.4-2.3.9a7.6 7.6 0 0 0-1.7-1l-.3-2.5h-4l-.3 2.5a7.6 7.6 0 0 0-1.7 1l-2.3-.9-2 3.4L6.6 11a7.6 7.6 0 0 0 0 2l-2 1.5 2 3.4 2.3-.9c.5.4 1.1.8 1.7 1l.3 2.5h4l.3-2.5c.6-.2 1.2-.6 1.7-1l2.3.9 2-3.4-2-1.5Z " /></ svg >
Paramètres
</ a >
2026-10-14 13:04:40 +02:00
@ if ( ! $estProf )
< a href = " { { route('mes-fichiers') }} " role = " menuitem " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 1.8 " >< rect x = " 3.5 " y = " 7 " width = " 17 " height = " 13 " rx = " 2 " />< path d = " M3.5 7l1.5-3h6l1.5 3 " /></ svg >
Mes fichiers
</ a >
@ endif
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
< a href = " { { route('aide.app') }} " role = " menuitem " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 1.8 " >< circle cx = " 12 " cy = " 12 " r = " 9 " />< path d = " M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1 .8-1 1.7v.5 " />< path d = " M12 17h.01 " /></ svg >
Aide et assistance
</ a >
< button type = " button " id = " app-signaler-bouton " role = " menuitem " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 1.8 " >< path d = " M5 3v18 " />< path d = " M5 4h11l-2.5 3.5L16 11H5 " /></ svg >
Signaler un problème
</ button >
< hr >
< form action = " { { route('deconnexion') }} " method = " post " >@ csrf
< button type = " submit " role = " menuitem " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 1.8 " >< path d = " M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 " />< path d = " M16 17l5-5-5-5 " />< path d = " M21 12H9 " /></ svg >
Se déconnecter
</ button >
</ form >
</ div >
</ div >
</ header >
< div class = " app-shell " >
< nav class = " app-sidebar " aria - label = " Navigation de l'application " >
< ul class = " app-sidebar__nav " >
@ include ( 'partials.nav-application' )
</ ul >
< p class = " app-sidebar__titre " > Mes raccourcis </ p >
< ul class = " app-sidebar__nav " >
@ foreach ( $raccourcis as $raccourci )
< li class = " app-sidebar__raccourci " >
< a href = " { { $raccourci->url }} " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 1.8 " >< path d = " M12 17v5 " />< path d = " M8 3h8l-1 6 2 2v2H7v-2l2-2-1-6Z " /></ svg >
{{ $raccourci -> label }}
</ a >
< form action = " { { route('raccourcis.destroy', $raccourci ) }} " method = " post " onsubmit = " return confirm('Retirer ce raccourci ?') " >
@ csrf
< button type = " submit " aria - label = " Retirer ce raccourci " > × </ button >
</ form >
</ li >
@ endforeach
</ ul >
< button type = " button " id = " app-epingler-bouton " class = " app-sidebar__epingler " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 1.8 " width = " 16 " height = " 16 " >< path d = " M12 5v14M5 12h14 " /></ svg >
Épingler cette page
</ button >
< form action = " { { route('raccourcis.store') }} " method = " post " id = " app-epingler-form " hidden class = " mt-2 grid gap-2 px-1 " >
@ csrf
< input type = " hidden " name = " url " id = " app-epingler-url " >
< input type = " text " name = " label " id = " app-epingler-label " required maxlength = " 80 " class = " rounded-lg border px-2 py-1 text-sm " style = " border-color:var(--sy-line);background:var(--sy-bg);color:var(--sy-ink) " >
< button type = " submit " class = " rounded-lg px-2 py-1 text-sm font-bold text-white " style = " background:var(--sy-blue) " > Épingler </ button >
</ form >
</ nav >
< main class = " app-main " >
@ yield ( 'contenu' )
</ main >
</ div >
< nav class = " app-tabbar " aria - label = " Navigation (mobile) " >
< ul class = " app-tabbar__liste " >
@ include ( 'partials.nav-application' , [ 'mobile' => true ])
< li >
< button type = " button " id = " app-plus-bouton " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 1.8 " >< circle cx = " 5 " cy = " 12 " r = " 1.5 " />< circle cx = " 12 " cy = " 12 " r = " 1.5 " />< circle cx = " 19 " cy = " 12 " r = " 1.5 " /></ svg >
Plus
</ button >
</ li >
</ ul >
</ nav >
< div class = " app-feuille " id = " app-feuille-plus " role = " dialog " aria - label = " Plus " >
< div class = " app-feuille__entete " >
< strong > Plus </ strong >
< button type = " button " id = " app-feuille-fermer " aria - label = " Fermer " > ✕ </ button >
</ div >
< div class = " app-feuille__contenu " >
@ include ( 'partials.nav-application' , [ 'mobilePlus' => true ])
< hr style = " border-color:var(--sy-line);margin:8px 0 " >
< a href = " { { route('profil') }} " > Mon profil </ a >
< a href = " { { route('parametres') }} " > Paramètres </ a >
< a href = " { { route('aide.app') }} " > Aide et assistance </ a >
< form action = " { { route('deconnexion') }} " method = " post " >@ csrf < button type = " submit " > Se déconnecter </ button ></ form >
</ div >
</ div >
< div class = " app-chat-bulle " role = " presentation " >
< button type = " button " id = " app-chat-bouton " aria - label = " Ouvrir le chat " aria - expanded = " false " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 2 " >< path d = " M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z " /></ svg >
< span class = " app-chat-bulle__badge " id = " app-chat-badge " hidden ></ span >
</ button >
</ div >
< div class = " app-chat-fenetre " id = " app-chat-fenetre " hidden ></ div >
< div id = " app-signaler-fenetre " hidden style = " position:fixed;inset:0;z-index:80;background:rgba(15,23,42,.4);display:flex;align-items:center;justify-content:center;padding:20px " >
< form action = " { { route('signalements.store') }} " method = " post " style = " background:var(--sy-surface);border-radius:16px;padding:20px;max-width:420px;width:100%;display:grid;gap:10px " >
@ csrf
< input type = " hidden " name = " page_url " value = " { { request()->fullUrl() }} " >
< p style = " font-weight:800;color:var(--sy-ink) " > Signaler un problème </ p >
< textarea name = " message " required maxlength = " 2000 " rows = " 4 " placeholder = " Décris ce qui ne marche pas… " style = " border:1px solid var(--sy-line);border-radius:10px;padding:10px;background:var(--sy-bg);color:var(--sy-ink) " ></ textarea >
< div style = " display:flex;gap:8px;justify-content:flex-end " >
< button type = " button " id = " app-signaler-annuler " style = " border:0;background:none;font-weight:700;color:var(--sy-muted) " > Annuler </ button >
< button type = " submit " style = " border:0;border-radius:999px;background:var(--sy-blue);color:#fff;padding:8px 18px;font-weight:800 " > Envoyer </ button >
</ div >
</ form >
</ div >
< script nonce = " { { Vite::cspNonce() }} " >
( function () {
function basculer ( bouton , panneau ) {
var ouvert = ! panneau . hidden ;
panneau . hidden = ouvert ;
bouton . setAttribute ( 'aria-expanded' , String ( ! ouvert ));
}
var avatarBtn = document . getElementById ( 'app-avatar-bouton' );
var avatarMenu = document . getElementById ( 'app-avatar-menu' );
avatarBtn . addEventListener ( 'click' , function ( e ) { e . stopPropagation (); basculer ( avatarBtn , avatarMenu ); });
document . addEventListener ( 'click' , function ( e ) { if ( ! avatarMenu . hidden && ! avatarMenu . contains ( e . target ) && e . target !== avatarBtn ) avatarMenu . hidden = true ; });
var plusBtn = document . getElementById ( 'app-plus-bouton' );
var feuille = document . getElementById ( 'app-feuille-plus' );
if ( plusBtn ) plusBtn . addEventListener ( 'click' , function () { feuille . style . display = 'block' ; });
document . getElementById ( 'app-feuille-fermer' ) . addEventListener ( 'click' , function () { feuille . style . display = 'none' ; });
var signalerBtn = document . getElementById ( 'app-signaler-bouton' );
var signalerFenetre = document . getElementById ( 'app-signaler-fenetre' );
signalerBtn . addEventListener ( 'click' , function () { avatarMenu . hidden = true ; signalerFenetre . style . display = 'flex' ; });
document . getElementById ( 'app-signaler-annuler' ) . addEventListener ( 'click' , function () { signalerFenetre . style . display = 'none' ; });
var epinglerBtn = document . getElementById ( 'app-epingler-bouton' );
var epinglerForm = document . getElementById ( 'app-epingler-form' );
if ( epinglerBtn ) epinglerBtn . addEventListener ( 'click' , function () {
document . getElementById ( 'app-epingler-url' ) . value = window . location . pathname ;
document . getElementById ( 'app-epingler-label' ) . value = document . title . replace ( / · Syllabus $ / , '' );
epinglerForm . hidden = false ;
epinglerBtn . hidden = true ;
});
})();
</ script >
< script nonce = " { { Vite::cspNonce() }} " >
( function () {
var ROLE = @ json ( $estProf ? 'prof' : 'eleve' );
var CSRF = @ json ( csrf_token ());
var URLS = @ if ( $estProf )
{ index : @ json ( route ( 'chat.prof.index' )), etat : @ json ( url ( '/chat' )) + '/__ID__/etat' , envoyer : @ json ( url ( '/chat' )) + '/__ID__/envoyer' }
@ else
{ etat : @ json ( route ( 'chat.eleve.etat' )), envoyer : @ json ( route ( 'chat.eleve.envoyer' )) }
@ endif ;
var bulle = document . getElementById ( 'app-chat-bouton' );
var fenetre = document . getElementById ( 'app-chat-fenetre' );
var badge = document . getElementById ( 'app-chat-badge' );
var ouverte = false , filActuel = null , minuteur = null ;
function echapper ( s ) { return String ( s ) . replace ( / [ &<> " ]/g, function (c) { return { '&': '&', '<': '<', '>': '>', ' " ': ' & quot ; ' }[ c ]; }); }
function rendreMessages ( corps , messages , actif ) {
var html = '<div class="app-chat-fenetre__corps" id="app-chat-corps">' ;
if ( messages . length === 0 ) html += '<p style="color:var(--sy-muted);font-size:13px;font-weight:600">Aucun message pour l’ instant.</p>' ;
messages . forEach ( function ( m ) {
html += '<p class="app-bulle-message app-bulle-message--' + m . expediteur + '">' + echapper ( m . contenu ) + '</p>' ;
});
html += '</div>' ;
if ( actif ) {
html += '<form class="app-chat-fenetre__pied" id="app-chat-form"><input type="text" name="contenu" maxlength="2000" placeholder="Écrire un message…" required><button type="submit">Envoyer</button></form>' ;
} else {
html += '<p class="app-chat-fenetre__desactive">Le chat est désactivé pour cette classe.</p>' ;
}
corps . innerHTML = html ;
var zone = document . getElementById ( 'app-chat-corps' );
zone . scrollTop = zone . scrollHeight ;
var form = document . getElementById ( 'app-chat-form' );
if ( form ) form . addEventListener ( 'submit' , function ( e ) {
e . preventDefault ();
var champ = form . querySelector ( 'input' );
var texte = champ . value . trim ();
if ( ! texte ) return ;
var url = ROLE === 'prof' ? URLS . envoyer . replace ( '__ID__' , filActuel ) : URLS . envoyer ;
fetch ( url , { method : 'POST' , headers : { 'X-CSRF-TOKEN' : CSRF , 'Content-Type' : 'application/json' , Accept : 'application/json' }, body : JSON . stringify ({ contenu : texte }) })
. then ( function () { champ . value = '' ; sonderConversation (); }) . catch ( function () {});
});
}
function sonderConversation () {
var url = ROLE === 'prof' ? URLS . etat . replace ( '__ID__' , filActuel ) : URLS . etat + '?lu=1' ;
fetch ( url , { headers : { Accept : 'application/json' } }) . then ( function ( r ) { return r . json (); }) . then ( function ( d ) {
rendreMessages ( fenetre , d . messages , d . actif );
}) . catch ( function () {});
}
function rendreEntete ( titre , retour ) {
return '<div class="app-chat-fenetre__entete">' +
( retour ? '<button type="button" id="app-chat-retour" aria-label="Retour">←</button>' : '' ) +
'<span style="flex:1">' + echapper ( titre ) + '</span>' +
'<button type="button" id="app-chat-fermer" aria-label="Fermer">✕</button></div>' ;
}
function ouvrirConversation ( id , titre ) {
filActuel = id ;
fenetre . innerHTML = rendreEntete ( titre , ROLE === 'prof' );
sonderConversation ();
attacherFermeture ();
if ( ROLE === 'prof' ) document . getElementById ( 'app-chat-retour' ) . addEventListener ( 'click' , ouvrirInbox );
}
function ouvrirInbox () {
filActuel = null ;
fenetre . innerHTML = rendreEntete ( 'Messages' , false ) + '<ul class="app-chat-fenetre__liste-fils" id="app-chat-fils"></ul>' ;
attacherFermeture ();
fetch ( URLS . index , { headers : { Accept : 'application/json' } }) . then ( function ( r ) { return r . json (); }) . then ( function ( d ) {
var liste = document . getElementById ( 'app-chat-fils' );
if ( ! liste ) return ;
if ( d . fils . length === 0 ) { liste . innerHTML = '<li style="padding:10px 6px;color:var(--sy-muted);font-size:13px">Aucune conversation pour l’ instant.</li>' ; return ; }
liste . innerHTML = d . fils . map ( function ( f ) {
return '<li><button type="button" data-fil="' + f . id + '" data-eleve="' + echapper ( f . eleve ) + '">' + echapper ( f . eleve ) + ' · ' + echapper ( f . classe ) +
( f . non_lus ? ' <span class="app-sidebar__badge">' + f . non_lus + '</span>' : '' ) + '</button></li>' ;
}) . join ( '' );
liste . querySelectorAll ( 'button[data-fil]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function () { ouvrirConversation ( b . dataset . fil , b . dataset . eleve ); });
});
}) . catch ( function () {});
}
function attacherFermeture () {
document . getElementById ( 'app-chat-fermer' ) . addEventListener ( 'click' , fermer );
}
function fermer () {
ouverte = false ;
fenetre . hidden = true ;
fenetre . classList . remove ( 'est-ouverte' );
bulle . setAttribute ( 'aria-expanded' , 'false' );
if ( minuteur ) clearInterval ( minuteur );
}
function ouvrir () {
ouverte = true ;
fenetre . hidden = false ;
fenetre . classList . add ( 'est-ouverte' );
bulle . setAttribute ( 'aria-expanded' , 'true' );
if ( ROLE === 'eleve' ) ouvrirConversation ( null , 'Mon prof' ); else ouvrirInbox ();
minuteur = setInterval ( function () { if ( filActuel !== null || ROLE === 'eleve' ) sonderConversation (); }, 3000 );
}
bulle . addEventListener ( 'click' , function () { ouverte ? fermer () : ouvrir (); });
function sonderBadge () {
if ( ROLE === 'eleve' ) {
fetch ( URLS . etat , { headers : { Accept : 'application/json' } }) . then ( function ( r ) { return r . json (); }) . then ( function ( d ) {
badge . hidden = ! d . non_lus ;
badge . textContent = d . non_lus ;
}) . catch ( function () {});
} else {
fetch ( URLS . index , { headers : { Accept : 'application/json' } }) . then ( function ( r ) { return r . json (); }) . then ( function ( d ) {
var total = d . fils . reduce ( function ( s , f ) { return s + f . non_lus ; }, 0 );
badge . hidden = total === 0 ;
badge . textContent = total ;
}) . catch ( function () {});
}
}
sonderBadge ();
setInterval ( function () { if ( ! ouverte ) sonderBadge (); }, 15000 );
})();
</ script >
@ stack ( 'scripts_bas' )
</ body >
</ html >