1/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2 * LES SIX ESPACES 3 * âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4 * 5 * TROISIÃME MORCEAU DÃTACHà DE `src/app.js` â Découvrir, Finance, CFO, 6 * Ãducation, Santé, Brevets. 730 lignes qui forment une aire du produit, et 7 * qui étaient noyées entre les studios et le chat. 8 * 9 * ââ CE QUE CE REGROUPEMENT REND VISIBLE âââââââââââââââââââââââââââââââââââââ 10 * 11 * Les six écrans partagent quatre choses, et c'est en les voyant côte à côte 12 * qu'on repère celui qui manque à l'appel : 13 * 14 * `_spaceChatArea(id, â¦)` le fil de discussion et sa rangée d'actions 15 * `SPACE_SUGGESTIONS[id]` les trois questions d'amorce 16 * `_etatVivantDeLEspace(id)` ce qui s'ajoute à la gorge : un état, jamais une identité 17 * `renderUniversalInputBar` la barre où l'on écrit 18 * 19 * Découvrir avait les trois premières et pas la quatrième. Résultat : 20 * `bindSpaceChat('espaceDecouvrir', â¦)` sortait à sa PREMIÃRE ligne, faute de 21 * bouton d'envoi (`if (!sendBtn || !inp) return`). Quatre déclarations, zéro 22 * branchement, zéro erreur â impossible à voir tant que les six écrans étaient 23 * dispersés. `tests/boucle-partout-smoke.mjs` vérifie maintenant les quatre, 24 * pour les six. 25 * 26 * ââ CE QUI VIT ENCORE DANS LE TRONC, ET POURQUOI ââââââââââââââââââââââââââââ 27 * 28 * `bindSpaceChat` est appelé au câblage depuis le tronc, et il inscrit chaque 29 * espace au registre de relance (`inscrireRelanceStudio`) qui vit dans 30 * `src/app-message-gestes.js`. Trois fichiers pour un geste : c'est le prix de 31 * l'ordre de chargement, et c'est tenu par une garde plutôt que par la mémoire. 32 * 33 * ââ LA RÃGLE DU DÃCOUPAGE âââââââââââââââââââââââââââââââââââââââââââââââââââ 34 * 35 * `src/app.js` est un script classique. Ce fichier se charge APRÃS lui : il 36 * peut donc lire ses `const` (`STATE`, `I`, les tables) et appeler ses 37 * fonctions â elles sont déjà là . L'inverse est interdit : le tronc ne doit 38 * rien appeler d'ici pendant son propre chargement, sous peine de tomber 39 * ENTIER sur un ReferenceError. Les deux moitiés sont tenues par 40 * `tests/scripts-charges-smoke.mjs`. 41 */ 42 43/* eslint-disable no-unused-vars */ 44 45// Fil de discussion léger commun aux SIX espaces â Découvrir, Finance, CFO, 46// Ãducation, Santé, Brevets. 47// 48// Le commentaire disait « 5 espaces », et il avait raison sur le fait sans le 49// savoir : Découvrir déclarait un chat en quatre endroits (suggestions, prompt 50// système, profil d'outils, appel à bindSpaceChat) et n'avait pas de champ où 51// écrire. `bindSpaceChat('espaceDecouvrir', â¦)` sortait à sa première ligne, 52// faute de bouton d'envoi. Un nombre écrit dans un commentaire ne vaut rien â 53// mais il désignait un trou. 54// 55// N'apparaît qu'une fois la 1ère question posée dans l'espace. 56// `enLigne` : la conversation est posée dans le flux de la page, juste sous la barre 57// de question (espaces dont la saisie est EN HAUT â Ãducation, Santé, Brevets), et 58// non dans un encart épinglé en bas de l'écran. Sans cela on écrivait en haut et la 59// réponse apparaissait tout en bas, loin du regard. Finance et CFO gardent l'encart 60// épinglé : leur barre de question est déjà en bas, la conversation juste au-dessus. 61function _spaceChatArea(id, suggestions, enLigne) { 62 const chat = (STATE.studioChats || {})[id] || [] 63 if (!chat.length) { 64 if (!suggestions?.length) return '' 65 // 2 colonnes (pas une longue liste verticale à 1 colonne) â chaque item 66 // pré-remplit la barre de question CREA de l'espace et l'envoie directement. 67 return `<div class="max-w-2xl mx-auto w-full px-4 pb-3"> 68 <p class="text-center text-[max(0.6875rem,11px)] font-bold uppercase tracking-wide mb-2" style="color:var(--t-faint)">Suggestions pour vous</p> 69 <div class="grid grid-cols-2 gap-1.5">${suggestions.map(s => `<button data-space-suggestion="${escHtml(s)}" data-space-suggestion-id="${id}" class="flex w-full items-start gap-2 rounded-xl px-3 py-2.5 text-left text-[max(0.78125rem,12.5px)] transition-colors hover:bg-black/5 min-w-0" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)"> 70 ${_icon('search', 'w-3.5 h-3.5 shrink-0 mt-0.5')}<span>${escHtml(s)}</span> 71 </button>`).join('')}</div> 72 </div>` 73 } 74 // Dans le flux, la conversation suit le défilement de la page : pas de hauteur 75 // plafonnée ni de second ascenseur imbriqué, qui obligeraie
75nt à faire défiler 76 // dans un cadre à l'intérieur d'une page qui défile déjà . 77 const cadre = enLigne ? 'mb-5 space-y-2' : 'pb-3 space-y-2 max-h-[38vh] overflow-y-auto' 78 // ET LA MÃME RANGÃE D'ACTIONS QUE LES STUDIOS. Les six espaces n'en avaient 79 // aucune : ni copier, ni relancer, ni supprimer. Une réponse ratée dans 80 // l'espace Santé ne pouvait qu'être relue â il fallait retaper la question. 81 /* ââ LE PEIGNE ARRIVE ICI AUSSI âââââââââââââââââââââââââââââââââââââââââ 82 * 83 * « Le peigne, c'est le système des pointillés à côté là qui permet de suivre 84 * tout ce qu'il y a dans le chat. Il faut venir aussi l'ajouter dans les 85 * studios et dans les autres espaces de discussion. » 86 * 87 * DEUX CHOSES MANQUAIENT, ET LA PREMIÃRE EST UNE ANCRE. Un message d'espace 88 * n'avait aucun identifiant : ni `id`, ni `data-msgid`. Un peigne sans ancre 89 * s'affiche et ne mène nulle part â le dépôt appelle ça un bouton qui ne lève 90 * rien. Chaque message posé porte donc `id="espace-msg-<lieu>-<rang>"`, et 91 * `data-jump` le retrouve par `getElementById`, comme dans les studios. 92 * 93 * LA SECONDE EST UN REPÃRE. Le fil d'un espace coule DANS la page â les trois 94 * qui en ont un (Ãducation, Santé, Brevets) passent tous `enLigne`. Il n'a 95 * donc pas de cadre borné où accrocher un peigne en `position:absolute` 96 * comme le fait le chat : il se poserait au milieu de la LISTE et défilerait 97 * avec elle. `position:sticky` le tient au milieu de la fenêtre pendant que le 98 * fil passe derrière â voir `.crea-peigne--espace`. */ 99 return `<div class="max-w-2xl mx-auto w-full px-4 ${cadre}" id="space-chatlog-${id}" style="position:relative"> 100 ${typeof window.renderStudioMinimapEspace === 'function' ? window.renderStudioMinimapEspace(id, chat) : ''} 101 ${chat.map((m, i) => m.role === 'user' 102 ? `<div id="espace-msg-${escHtml(id)}-${i}" class="flex flex-col items-end"><div class="rounded-2xl px-3 py-2 max-w-[85%] text-[max(0.8125rem,13px)]" style="background:var(--accent);color:white">${escHtml(m.content || '')}</div>${renderStudioMsgActions(id, i, true)}</div>` 103 // LE MÃME AFFICHAGE QUE LE CHAT, pas un second. `renderStepsZone` porte 104 // déjà le tour replié, le plan, la compaction et les tâches de fond : en 105 // réécrire une version pour les espaces, c'est se garantir que l'une des 106 // deux prendra du retard sur l'autre. 107 // La réflexion se lit ICI AUSSI, et par la MÃME fonction que le chat â 108 // pas par une seconde version qui prendrait du retard sur la première. 109 : `<div class="flex justify-start"><div class="max-w-[90%] w-full"> 110 ${typeof renderStepsZone === 'function' ? renderStepsZone(m, !!m.pending) : ''} 111 ${typeof renderReflexion === 'function' ? renderReflexion(m, !!m.pending, m.reasoning, 'Réflexion', 'flux') : ''} 112 <div class="rounded-2xl px-3 py-2 text-[max(0.8125rem,13px)] msg-content" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">${m.pending && !m.content ? '<span class="opacity-50">â¦</span>' : renderMarkdown(m.content || '')}</div> 113 ${m.pending && !m.content ? '' : renderStudioMsgActions(id, i, false)} 114 </div></div>` 115 ).join('')} 116 </div>` 117} 118const SPACE_SUGGESTIONS = { 119 espaceDecouvrir: ['Résume les actualités du jour', 'Quels sont les sujets tendance en ce moment ?', "Explique-moi un sujet d'actualité simplement"], 120 espaceProjets: ['Où en sont mes projets ?', 'Quelles tensions demandent une décision ?', 'Quel objectif est le plus en retard ?'], 121 espaceRelations: ['Qui porte quel projet ?', 'Résume mes relations de travail', 'Qui dois-je recontacter ?'], 122 espaceJuridique: ['Qu\'est-ce qui change dans la réglementation ?', 'Explique-moi le RGPD simplement', 'Que dois-je vérifier pour être en conformité ?'], 123 espaceFinance: ["Explique-moi l'évolution récente du Bitcoin", "Qu'est-ce qu'un ETF ?", 'Comment lire un graphique boursier ?'], 124 espaceCfo: ['Comment établir un budget mensuel ?', 'C\'est quoi la règle 50/30/20 ?', 'Comment commencer à investir ?'], 125 espaceEducation: ['Explique-moi la relativité restreinte simplement', 'Résume les grands courants de philosophie politique', 'Aide-moi à préparer un exposé'], 126 espaceBrevets: ['Comment savoir si une idée est brevetable ?', "Différence entre brevet et droit d'auteur ?", "Combien coûte le dépôt d'un brevet ?"], 127 espaceSante: ['Que faire en cas de fièvre légère ?', 'Comment améliorer mon sommeil ?', "C'est quoi une alimentation équilibrée ?"], 128} 129 130function _etatVivantDeLEspace(id) {
131 /* UN ESPACE EST UN LIEU, PAS QUELQU'UN D'AUTRE. 132 * 133 * `_common` ouvrait par « Tu es CREA, l'assistant IA de la plateforme CREA » 134 * puis chaque espace ajoutait son « Ici : ⦠» â dix Créa parallèles, écrites 135 * ici, lues sans l'organisme. Comme les douze studios avant elles. 136 * 137 * L'espace dit maintenant d'où il parle, à L'ENVOI : 138 * `buildSystemPrompt(false, demande, id)`. Son lieu et son « Ici : ⦠» 139 * vivent dans `STUDIOS` (app-domaine-surface.js) et montent par LA gorge â 140 * avec les compétences, les fichiers joints et toutes les gardes, que 141 * `_common` recopiait à moitié. 142 * 143 * Ce qui reste ici est un ÃTAT VIVANT, pas une identité : les cours crypto 144 * réels de l'espace Finance, lus au moment de l'envoi. */ 145 const fd = (STATE.spaceData || {}).espaceFinance 146 return (id === 'espaceFinance' || id === 'espaceCfo') && fd?.crypto?.length 147 ? '\n\nCours crypto actuels (réels, CoinGecko) : ' + fd.crypto.map(c => `${c.symbol} ${c.price}$ (${c.change24h >= 0 ? '+' : ''}${(c.change24h || 0).toFixed(1)}%)`).join(', ') + '.' 148 : '' 149} 150 151// Câblage du chat d'un espace â appelé sans condition à chaque render() (comme le 152// reste des bindings CREA) : no-op si les boutons ne sont pas dans le DOM actuel. 153function bindSpaceChat(id) { 154 const sendBtn = document.getElementById('btn-space-send-' + id) 155 const inp = document.getElementById('space-input-' + id) 156 if (!sendBtn || !inp) return 157 // Même chose que dans les studios rapides : l'envoi porte un nom, donc la 158 // rangée d'actions peut le rappeler pour rejouer un message. 159 const envoyer = async (texteImpose = null) => { 160 const text = texteImpose != null ? String(texteImpose) : inp.value?.trim() 161 if (!text && !_hasComposerAttachment()) return 162 STATE.studioChats ||= {}; STATE.studioChats[id] = STATE.studioChats[id] || [] 163 // Fichiers joints / blocs collés : consommés ici comme le chat principal (sinon 164 // ils s'affichaient en chip mais leur contenu n'était jamais lu par l'IA â CREA 165 // "lisait mal les fichiers" dans les espaces). 166 const _files = (STATE.attachedFiles || []).map(f => ({ name: f.name, type: f.type, dataUrl: f.dataUrl, extractedText: f.extractedText || undefined })) 167 const _pasteBlocks = (STATE.pasteBlocks || []).map(b => b.text) 168 STATE.attachedFiles = []; STATE.pasteBlocks = [] 169 const _quotePrefix = _consumeReplyQuoteContext() 170 STATE.studioChats[id].push({ role: 'user', content: _quotePrefix + text, files: _files.length ? _files : undefined, pasteBlocks: _pasteBlocks.length ? _pasteBlocks : undefined }) 171 // Les champs de la boucle sont posés dès la création du message : c'est ce 172 // qui permet à `renderStepsZone` â le MÃME affichage que le chat â de 173 // montrer les étapes, le plan et la compaction ici aussi. 174 STATE.studioChats[id].push({ id: uid(), role: 'assistant', content: '', pending: true, steps: [], _boucleServeur: true, _phase: 'connexion', _debutBoucle: Date.now(), _jetons: 0 }) 175 inp.value = '' 176 // Vider aussi STATE.inputDrafts, sinon le prochain render() régénère la barre 177 // avec l'ancien texte (inp.value='' seul ne suffit pas â c'était le bug). 178 setInputDraft('space-input-' + id, '') 179 render() 180 try { 181 const _visionOK = STATE.selectedModel?.provider !== 'local' && STATE.selectedModel?.provider !== 'llama' && STATE.selectedModel?.provider !== 'hf' && STATE.selectedModel?.vision 182 const history = STATE.studioChats[id].slice(0, -1).map(m => ({ role: m.role === 'user' ? 'user' : 'assistant', content: m.role === 'user' ? toApiContent(m, _visionOK) : (m.content || '').slice(0, 4000) })) 183 const msg = STATE.studioChats[id][STATE.studioChats[id].length - 1]; const reperesEspace = {} 184 const majBulle = () => { 185 const logEl = document.getElementById('space-chatlog-' + id) 186 const bulle = logEl?.querySelector('div:last-child .msg-content') 187 if (bulle) bulle.textContent = msg.content || '' 188 } 189 const majReflexion = () => { 190 const logEl = document.getElementById('space-chatlog-' + id) 191 const corps = logEl?.querySelector('div:last-child .reasoning-body') 192 if (corps) corps.textContent = msg.reasoning || '' 193 } 194 // ââ L'ESPACE PASSE PAR LA BOUCLE, COMME LE RESTE âââââââââââââââââââ 195 // 196 // « Il faut les relier aussi là -bas. » 197 // 198 // Il postait sur /api/chat : un seul tour, ZÃRO outil. L'espace Finance 199 // ne pouvait pas aller chercher un cours, l'espace Découvrir parlait de 200 // l'actualité sans jamais la lire, l'espace Santé donnait un avis sans 201 // ouvrir une source. Ils répondaient de mémoire â celle du modèle, arrêtée 202 // à sa date d'entraînement â avec le même aplomb que le chat outillé. 203 // 204 // Ils héritent maintenant de tout ce que la boucle serveur porte : le 205 // plan, la compaction, les trois plafonds, la reprise après coupure, et 206 // les outils DE LEUR DOMAINE (voir PROFILS_DE_SURFACE) â pas ceux du chat. 207 const profil = profilDeSurface(id) 208 const outilsAutorises = (() => { try { return agentToolsFor(id).map((t) =>
208 t.function?.name).filter(Boolean) } catch (e) { return null } })() 209 const sortie = await runAgentBoucleStream({ 210 // `runAgentBoucleStream` canonise lui-même : le refaire ici 211 // envelopperait deux fois le même texte. 212 messages: history, 213 /* LA GORGE, SOUS LE LIEU DE L'ESPACE, composée à L'ENVOI : les terrains 214 jugent cette phrase-ci, pas le dernier message du fil de chat. */ 215 systemPrompt: buildSystemPrompt(false, text, id) + _etatVivantDeLEspace(id), 216 /* DâOà LâON PARLE, ET CE QUI SURVIT ICI DâUN MESSAGE à LâAUTRE. 217 `surface` : ceinture, si la gorge cessait dâêtre appelée au-dessus, le 218 plancher poserait le lieu de lâespace au lieu de celui du chat. 219 `memoire` : sa session (donc la reprise après coupure, que le 220 commentaire ci-dessus promettait sans la brancher), son arrêt, et les 221 outils quâil a déjà ouverts. Voir memoireDuLieu, dans le tronc. */ 222 surface: id, 223 memoire: id, 224 model: STATE.selectedModel.id, 225 provider: STATE.selectedModel.provider || undefined, 226 authParams: internalAuth(), 227 outilsAutorises, 228 options: { maxTours: profil.maxTours }, 229 onPhase: (phase) => { if (msg._phase !== phase) { msg._phase = phase; render() } }, 230 /* ââ UN OUTIL A RENDU QUELQUE CHOSE : ON LE MONTRE ââââââââââââââââ 231 * 232 * `onArtifact` n'était pas branché ici. Le défaut de 233 * `runAgentBoucleStream` est une fonction vide : le serveur produisait 234 * l'artefact, il était payé, et il tombait. Un outil qui rapporte un 235 * tableau, un JSON, un bout de code â dans les six espaces, ça ne se 236 * voyait nulle part. 237 * 238 * MESURà AVANT DE BRANCHER, et c'est la seule chose qui autorise ce 239 * fil : le panneau d'artefacts s'affiche BIEN dans un espace â 478Ã642 240 * dans Réseau, Collab, Coworker et Dispatch. Brancher un signal vers un 241 * panneau absent aurait été refaire, à l'envers, le défaut qu'on répare. 242 * 243 * `artefactDeSortieDOutil` est le poste partagé : il normalise la 244 * sortie (le repli `kind:'json'` existe parce qu'un type inconnu 245 * finissait injecté comme du corps de page), la pose, et ouvre le 246 * panneau. On ne recopie pas ces six lignes â elles l'ont déjà été deux 247 * fois, à trente mille lignes d'écart. */ 248 onArtifact: (artefact) => { 249 try { if (artefactDeSortieDOutil(artefact)) render() } 250 catch (e) { /* un artefact mal formé coûte son affichage, jamais le tour d'agent */ } 251 }, 252 // LA RÃFLEXION ÃTAIT PRODUITE, FACTURÃE, ET JETÃE. 253 // 254 // Ce rappel n'était pas branché : le défaut de `runAgentBoucleStream` 255 // est une fonction vide. Les jetons de réflexion partaient, revenaient, 256 // étaient payés â et tombaient dans le vide. Le message porte déjà tout 257 // ce qu'il faut pour l'afficher (`renderReflexion` lit `reasoning`). 258 // Le nÅud est patché directement, comme la bulle : un rendu complet par 259 // fragment de réflexion fige l'écran â c'est la panne qu'on vient de 260 // retirer d'ailleurs, on ne la réintroduit pas ici. 261 onThinkingDelta: (delta) => { 262 const premier = !msg.reasoning 263 msg.reasoning = (msg.reasoning || '') + delta 264 msg.thinking = true 265 if (premier) { render(); return } 266 majReflexion() 267 }, 268 onTextDelta: (delta) => { msg.content = (msg.content || '') + delta; majBulle() }, 269 /* LE CINQUIÃME CHEMIN, ET IL AVAIT DÃJà ÃTà OUBLIà UNE FOIS : la frise 270 était éteinte ici, et ici seulement. Un repère qui ne descend pas 271 jusqu'ici rejouerait la même panne, en plus discret. */ 272 onToolAncrer: (id) => ouvrirEtape.ancrer(reperesEspace, id, msg), /* LE MÃME poste unique, par LA MÃME porte */ 273 onToolStart: (nom, parametres, id) => { 274 275 /* ââ LA FRISE ÃTAIT ÃTEINTE ICI, ET ICI SEULEMENT ââââââââââââââââ 276 * 277 * `marquerReflexion` note, au démarrage de chaque outil, la longueur 278 * qu'avait DÃJà le texte (`step._contenuA`). Deux repères consécutifs 279 * découpent ce qui a été écrit entre deux actes ; `friseEntrelace`
280 * s'allume dès qu'une étape en porte un, et le fil se lit alors dans 281 * l'ordre où les choses se sont passées â phrase, acte, phrase, acte. 282 * 283 * Les quatre autres chemins l'appellent (chat, studios Ã2, boucle 284 * serveur). Ce cinquième-ci, non : les six espaces montraient une 285 * pile d'actions d'un côté et un bloc de texte de l'autre, sans que 286 * rien ne dise quelle phrase avait décidé quel geste. 287 * 288 * Mesuré sur la vraie fonction : sans l'appel, `friseEntrelace` rend 289 * faux et `segmentContenu` rend '' pour chaque étape. Avec, les 290 * segments se découpent exactement aux endroits où le texte s'était 291 * arrêté pour agir. 292 */ 293 const step = ouvrirEtape(msg, nom, parametres, reperesEspace[id]) /* LE POSTE UNIQUE, dans le tronc (src/app.js) : ce fichier se charge APRÃS lui, et n'appelle qu'à l'exécution */ 294 render() 295 return step 296 }, 297 // Le MÃME geste que les trois autres surfaces. Cette copie-ci rangeait 298 // `d.resultat` BRUT â un objet â là où les lecteurs font String(â¦) : 299 // l'aperçu d'un résultat s'affichait « [object Object] » dans les six 300 // espaces. 301 onToolDone: (step, d) => { if (cloreEtape(step, d)) render() }, 302 onTour: (d) => { if (typeof d?.jetonsCumules === 'number') msg._jetons = d.jetonsCumules; if (typeof d?.jetonsPensee === 'number') msg._jetonsPensee = d.jetonsPensee }, 303 // Le MÃME aiguillage que le chat et les studios. Cette copie-ci ne 304 // rangeait ni la vérification ni les blocages : un espace pouvait 305 // repartir pour un tour de contrôle sans que rien ne l'explique. 306 onServerEvent: (evt) => { if (rangerEvenementBoucle(msg, evt)) render() }, 307 }) 308 msg.pending = false 309 // POURQUOI ÃA S'EST ARRÃTÃ, EN FRANÃAIS â la même phrase qu'ailleurs. Un 310 // espace qui rend la main sans rien dire ressemble à une panne. 311 if (!msg.content && sortie?.sortie && sortie.sortie !== 'termine') msg.content = phraseDeSortie(sortie.sortie, msg.steps) 312 if (!msg.content) msg.content = 'â Réponse vide' 313 save(); render() 314 } catch (e) { 315 const chat = STATE.studioChats[id]; const last = chat[chat.length - 1] 316 if (last?.pending) { last.pending = false; last.content = 'â ' + expliquerEchecReseau(e) } 317 render() 318 } 319 } 320 sendBtn.addEventListener('click', () => envoyer()) 321 inscrireRelanceStudio(id, (texte) => envoyer(texte)) 322 inp.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendBtn.click() } }) 323} 324 325// ââ LES CENTRES D'INTÃRÃT âââââââââââââââââââââââââââââââââââââââââââââââ 326// 327// Ils étaient CINQ. « Il faut trouver tous les centres d'intérêt qui existe 328// parce qu'il y en a beaucoup et les relier au flux. » 329// 330// LES DEUX MOTS COMPTENT AUTANT. « Beaucoup » est la partie visible ; « reliés 331// au flux » est celle qui décide si ça marche. Côté serveur, `Pour vous` fait : 332// 333// const choisis = (interets || []).filter(i => rubriques.includes(i)) 334// 335// â un identifiant absent de `RUBRIQUES` est jeté sans un mot, et si TOUS le 336// sont, le fil retombe sur l'actualité générale. L'usager verrait ses pastilles 337// cochées et recevrait Top. Une pastille qui ne filtre rien est pire qu'une 338// pastille absente : elle a l'air de marcher. 339// 340// Les identifiants sont donc les CLÃS EXACTES de `RUBRIQUES` 341// (server/crea-decouvrir.js), et `tests/decouvrir-interets-smoke.mjs` tient 342// l'égalité dans les deux sens. Les cinq d'origine gardent leur identifiant au 343// caractère près : ils sont enregistrés dans les préférences des comptes 344// existants, les renommer effacerait des choix déjà faits. 345// 346// Les icônes se répètent d'une famille à l'autre, et c'est voulu : le jeu de 347// traits en compte 51, et `_icon()` d'un nom inconnu rend un SVG VIDE â un 348// carré blanc, silencieusement. Mieux vaut une icône juste réemployée qu'un 349// trou qui ne se signale pas. 350const DISCOVER_INTERESTS = [ 351 // Technologie, sciences 352 { id: 'Technologie & Science', label: 'Technologie & Science', icon: _icon('computer') }, 353 { id: 'Intelligence artificielle', label: 'Intelligence artificielle', icon: _icon('brain') }, 354 { id: 'Espace & Astronomie', label: 'Espace & Astronomie', icon: _icon('science') }, 355 { id: 'Recherche & Innovation', label: 'Recherche & Innovation', icon: _icon('search') }, 356 // Argent, travail 357 { id: 'Entreprise', label: 'Entreprise', icon: _icon('money') }, 358 { id: 'Ãconomie & Finance', label: 'Ãconomie & Finance', icon: _icon('chart') }, 359 { id: 'Marchés & Bourse', label: 'Marchés & Bourse', icon: _icon('wallet') }, 360 { id: 'Cryptomonnaies', label: 'Cryptomonnaies', icon: _icon('card') }, 361 { id: 'Startups & Innovation', label: 'Startups & Innovation', icon: _icon('bolt') }, 362 { id: 'Emploi & Carrière', label: 'Emploi & Carrière', icon: _icon('briefcase') }, 363 { id: 'Immobilier', label: 'Immobilier', icon: _icon('home') }, 364 // Le monde, la cité 365 { id: 'Politique', label: 'Politique', icon: _icon('law') }, 366 { id: 'International', label: 'International', icon: _icon('globe') }, 367 { id: 'France', label: 'France', icon: _icon('home') }, 368 { id: 'Justice & Société', label: 'Justice & Société', icon: _icon('law') }, 369 { id: 'Droit & Réglementation', label: 'Droit & Réglementation', icon: _icon('doc') }, 370 { id: 'Défense & Sécurité', label: 'Défense & Sécurité', icon: _icon('shieldCheck') }, 371 { id: 'Médias & Réseaux sociaux', label: 'Médias & Réseaux sociaux', icon: _icon('network') }, 372 // Le vivant 373 { id: 'Santé & Médecine', label: 'Santé & Médecine', icon: _icon('health') }, 374 { id: 'Bien-être & Psychologie', label: 'Bien-être & Psychologie', icon: _icon('heart') }, 375 { id: 'Environnement & Climat', label: 'Environnement & Climat', icon: _icon('cloud') }, 376 { id: 'Ãnergie', label: 'Ãnergie', icon: _icon('bolt') }, 377 { id: 'Agriculture & Alimentation', label: 'Agriculture & Alimentation', icon: _icon('stack') }, 378 // Apprendre, créer 379 { id: 'Ãducation', label: 'Ãducation', icon: _icon('humanities') }, 380 { id: 'Histoire & Patrimoine', label: 'Histoire & Patrimoine', icon: _icon('journal') }, 381 { id: 'Arts & Culture', label: 'Arts & Culture', icon: _icon('palette') }, 382 { id: 'Livres & Littérature', label: 'Livres & Littérature', icon: _icon('doc') }, 383 { id: 'Musique', label: 'Musique', icon: _icon('star') }, 384 { id: 'Cinéma & Séries', label: 'Cinéma & Séries', icon: _icon('tv') }, 385 { id: 'Photographie', label: 'Photographie', icon: _icon('eye') }, 386 { id: 'Mode & Design', label: 'Mode & Design', icon: _icon('palette') }, 387 { id: 'Architecture & Urbanisme', label: 'Architecture & Urbanisme', icon: _icon('home') }, 388 // Loisirs 389 { id: 'Sport', label: 'Sports', icon: _icon('trophy') }, 390 { id: 'Jeux vidéo', label: 'Jeux vidéo', icon: _icon('desktop') }, 391 { id: 'Divertissement', label: 'Divertissement', icon: _icon('tv') }, 392 { id: 'Voyage', label: 'Voyage', icon: _icon('globe') }, 393 { id: 'Cuisine & Gastronomie', label: 'Cuisine & Gastronomie', icon: _icon('heart') }, 394 { id: 'Automobile & Mobilité', label: 'Automobile & Mobilité', icon: _icon('network') }, 395] 396function _weatherIcon(code) { 397 if (code === 0) return 'âï¸' 398 if (code <= 3) return code === 1 ? 'ð¤ï¸' : code === 2 ? 'â ' : 'âï¸' 399 if (code <= 48) return 'ð«ï¸' 400 if (code <= 57) return 'ð¦ï¸' 401 if (code <= 67) return 'ð§ï¸' 402 if (code <= 77) return 'âï¸' 403 if (code <= 82) return 'ð¦ï¸' 404 if (code <= 86) return 'ð¨ï¸' 405 return 'âï¸' 406} 407// ââ LE CHOIX DES CENTRES D'INTÃRÃT â UN SEUL PANNEAU, DEUX PORTES âââââââ 408// 409// Il s'ouvre depuis « Pour vous » (deuxième clic) et depuis « Personnaliser » 410// dans la colonne. Deux portes, une seule pièce : le dupliquer, c'est se 411// condamner à corriger deux fois â et à en oublier une, ce qui est le motif 412// qu'on passe la journée à défaire ici. 413// 414// Il DÃFILE. Avec trente-huit pastilles, un panneau sans hauteur bornée sort 415// de l'écran par le bas, et les dernières familles deviennent inatteignables 416// sans que rien ne le signale. 417function _decChoixInterets(interests) { 418 const familles = [ 419 ['Technologie & sciences', ['Technologie & Science', 'Intelligence artificielle', 'Espace & Astronomie', 'Recherche & Innovation']], 420 ['Argent & travail', ['Entreprise', 'Ãconomie & Finance', 'Marchés & Bourse', 'Cryptomonnaies', 'Startups & Innovation', 'Emploi & Carrière', 'Immobilier']], 421 ['Le monde & la cité', ['Politique', 'International', 'France', 'Justice & Société', 'Droit & Réglementation', 'Défense & Sécurité', 'Médias & Réseaux sociaux']], 422 ['Le vivant', ['Santé & Médecine', 'Bien-être & Psychologie', 'Environnement & Climat', 'Ãnergie', 'Agriculture & Alimentation']], 423 ['Apprendre & créer', ['Ãducation', 'Histoire & Patrimoine', 'Arts & Culture', 'Livres & Littérature', 'Musique', 'Cinéma & Séries', 'Photographie', 'Mode & Design', 'Architecture & Urbanisme']], 424 ['Loisirs', ['Sport', 'Jeux vidéo', 'Divertissement', 'Voyage', 'Cuisine & Gastronomie', 'Automobile & Mobilité']], 425 ] 426 const pastille = (id) => { 427 const it = DISCOVER_INTERESTS.find(x => x.id === id) 428 if (!it) return '' 429 const choisi = interests.includes(it.id)
430 return `<button data-discover-interest="${escHtml(it.id)}" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[max(0.6875rem,11px)] font-medium" style="${choisi ? 'background:var(--accent);color:white;border:1px solid var(--accent)' : 'background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)'}">${it.icon}${escHtml(it.label)}</button>` 431 } 432 return `<div id="dec-choix-interets" class="absolute top-full left-0 mt-1.5 z-30 rounded-2xl shadow-2xl anim-slide-down" style="background:var(--bg-input);border:1px solid var(--border);width:min(92vw,420px)"> 433 <div class="px-3.5 pt-3 pb-2" style="border-bottom:1px solid var(--border)"> 434 <p class="text-[max(0.78125rem,12.5px)] font-bold" style="color:var(--t-primary)">Vos centres d'intérêt</p> 435 <p class="text-[max(0.6875rem,11px)] mt-0.5" style="color:var(--t-dim)">Ils décident de ce que « Pour vous » va chercher. Sans aucun choix, le fil reste sur l'actualité générale.</p> 436 </div> 437 <div class="px-3.5 py-2.5" style="max-height:min(52vh,380px);overflow-y:auto"> 438 ${familles.map(([titre, ids]) => `<div class="mb-3"> 439 <p class="text-[max(0.625rem,10px)] font-semibold uppercase tracking-wide mb-1.5" style="color:var(--t-faint)">${escHtml(titre)}</p> 440 <div class="flex flex-wrap gap-1.5">${ids.map(pastille).join('')}</div> 441 </div>`).join('')} 442 </div> 443 <div class="px-3.5 py-2.5 flex items-center gap-2" style="border-top:1px solid var(--border)"> 444 <span class="text-[max(0.6875rem,11px)] flex-1" style="color:var(--t-faint)">${interests.length ? `${interests.length} sélectionné${interests.length > 1 ? 's' : ''}` : 'aucun choisi'}</span> 445 <button id="btn-discover-clear-interests" class="rounded-full px-3 py-1.5 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Tout retirer</button> 446 <button id="btn-discover-save-interests" class="rounded-full px-4 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="background:var(--t-primary);color:var(--bg-primary)">Enregistrer</button> 447 </div> 448 </div>` 449} 450 451function renderDecouvrirSpace() { 452 const d = (STATE.spaceData || {}).espaceDecouvrir || {} 453 const tab = STATE.discoverSpaceTab || 'top' 454 const interests = STATE.discoverInterests || [] 455 const weather = d.weather 456 // LE FIL EST RENDU PAR SON MODULE (src/crea-decouvrir-ui.js) : sujets 457 // regroupés, chapô, dates réelles, « N sources », défilement infini. Cet 458 // écran ne dessine plus que ce qui l'entoure. Avant, il fabriquait lui-même 459 // une vignette + trois cartes à partir des quatre premiers articles d'un 460 // appel qui en ramenait douze, et le fil s'arrêtait là . 461 const fil = renderVueInterne() || (window.CREA_DECOUVRIR 462 ? window.CREA_DECOUVRIR.rendre() 463 : `<div class="dec-etat">Module Le Fil indisponible.</div>`) 464 const sujetActif = tab === 'sujets' && STATE.discoverSujetTopic ? STATE.discoverSujetTopic : '' 465 const onglet = (id, libelle, extra) => `<button data-discover-tab="${id}" class="dec-onglet${tab === id ? ' on' : ''}">${escHtml(libelle)}${extra || ''}</button>` 466 return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell"> 467 ${_spaceMenuBtn()} 468 <div id="dec-defilement" class="flex-1 overflow-y-auto min-h-0"> 469 <div class="w-full px-4 md:px-8 pt-3 pb-8 md:flex md:gap-8"> 470 <div class="flex-1 min-w-0"> 471 <!-- UNE SEULE LIGNE : le titre a gauche, les onglets au centre, 472 Partager a droite. 473 474 Ils ont tenu leur propre ligne en dessous pendant un temps, au 475 nom de la disposition de reference. L'usager l'a corrige sur 476 photo : ils doivent etre sur la MEME ligne que Decouvrir et 477 Partager. On remplace la justification plutot que de la 478 contredire en silence, sinon le prochain les redescendra en 479 citant la reference. 480 481 flex-wrap garde la ligne lisible en etroit : sur un ecran qui 482 ne peut pas tenir les trois, les onglets passent dessous 483 d'eux-memes plutot que d'etre ecrases. --> 484 <!-- ââ LE CHEMIN DU RETOUR ââââââââââââââââââââââââââââââââââââ 485 Quand on arrive ici depuis une autre branche â « ce qu'on en 486 dit » d'un projet, la veille de Juridique â le point de départ 487 serait perdu sans ça : il faudrait rouvrir le menu, retrouver 488 la branche, et se rappeler ce qu'on y cherchait. Trois gestes 489 pour défaire un clic, et au bout de deux fois on ne
489clique 490 plus. C'est la différence entre des écrans côte à côte et des 491 surfaces qui se répondent. --> 492 ${STATE.filRetour?.workMode ? `<button id="btn-fil-retour" class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 mb-2.5 text-[max(0.71875rem,11.5px)] font-medium" style="background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent)"> 493 <svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" d="M15 19l-7-7 7-7"/></svg> 494 Retour à ${escHtml(STATE.filRetour.libelle || 'l\'espace précédent')} 495 </button>` : ''} 496 <div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 mb-3"> 497 ${_spaceTitre('Le Fil', 'text-[max(1.1875rem,19px)] font-bold', 'color:var(--t-primary)')} 498 <div class="dec-onglets"> 499 <!-- « POUR VOUS » A UNE FLÃCHE, COMME « SUJETS », ET DEUX TEMPS. 500 Premier clic
500: on ouvre le fil personnalisé. Deuxième clic, 501 alors qu'on y est déjà : on ouvre le choix des centres 502 d'intérêt. Le geste suit l'intention â on ne va régler ses 503 sujets qu'une fois qu'on regarde le fil qu'ils produisent, et 504 le réglage s'ouvre là où on l'a demandé, pas dans une colonne 505 à l'autre bout de l'écran. --> 506 <span class="relative"> 507 <button id="btn-discover-pourvous" class="dec-onglet${tab === 'pourvous' ? ' on' : ''}">Pour vous <svg class="w-3 h-3 inline-block transition-transform ${STATE.discoverInterestsOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg></button> 508 ${STATE.discoverInterestsOpen ? _decChoixInterets(interests) : ''} 509 </span> 510 ${onglet('top', 'Top')} 511 <!-- L'onglet de recherche n'existe QUE pendant une recherche. Un 512 onglet permanent et vide serait une promesse qu'on ne tient 513 pas ; celui-ci apparaît avec sa requête et disparaît avec 514 elle, et le cliquer y ramène depuis Top ou Sujets. --> 515 ${STATE.discoverQuery ? onglet('recherche', '« ' + String(STATE.discoverQuery).slice(0, 22) + ' »') : ''} 516 <!-- « Gardés » n'apparaît qu'une fois qu'on a gardé quelque chose : 517 un onglet toujours vide serait un reproche permanent. --> 518 ${(STATE.filGardes || []).length ? onglet('gardes', 'Gardés', ` <span style="opacity:.6">${(STATE.filGardes || []).length}</span>`) : ''} 519 <span class="relative"> 520 <button id="btn-discover-sujets" class="dec-onglet${tab === 'sujets' ? ' on' : ''}">${escHtml(sujetActif || 'Sujets')} <svg class="w-3 h-3 inline-block transition-transform ${STATE.discoverSujetsOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg></button> 521 ${STATE.discoverSujetsOpen ? `<div class="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 z-30 rounded-2xl p-1.5 shadow-2xl anim-slide-down" style="background:var(--bg-input);border:1px solid var(--border);min-width:230px;max-height:min(60vh,420px);overflow-y:auto"> 522 ${DISCOVER_INTERESTS.map(it => `<button data-discover-sujet="${escHtml(it.id)}" class="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-[max(0.78125rem,12.5px)]" style="color:var(--t-primary)">${it.icon}${escHtml(it.label)}</button>`).join('')} 523 </div>` : ''} 524 </span> 525 </div> 526 <!-- ââ CHERCHER DANS LE FIL ââââââââââââââââââââââââââââââââââââ 527 « Il y a Top, Pour vous, Sujets â mais si tu veux savoir ce 528 qu'on dit de quelque chose de précis aujourd'hui, rien. » 529 530 Le moteur savait déjà le faire : NewsAPI, GDELT et les flux 531 prennent une requête, le regroupement fond les reprises, la 532 pagination suit. Il manquait la possibilité de l'ÃCRIRE. 533 534 Un CHAMP, pas un bouton loupe qui ouvre un champ : le geste le 535 plus fréquent ne se met pas derrière un clic. Il rétrécit à 536 92 px sous 640 px de large plutôt que de pousser les onglets à 537 la ligne. --> 538 <div class="flex items-center gap-2 shrink-0"> 539 <div class="dec-recherche flex items-center gap-1.5 rounded-full px-2.5 py-1" style="background:var(--bg-input);border:1px solid var(--border)"> 540 ${_icon('search', 'w-3.5 h-3.5 shrink-0')} 541 ${/* LA VALEUR VIENT DE CE QU'ON TAPE, PAS DE LA DERNIÃRE RECHERCHE LANCÃE. 542 Ce champ ne se lance qu'à Entrée ou en le quittant (dix requêtes vers 543 trois API externes pour un mot de dix lettres, sinon) â mais son nÅud 544 est refait à CHAQUE render(), pour n'importe quelle raison. Ce qu'on 545 était en train de taper revenait donc à la dernière recherche lancée, 546 au milieu d'une frappe. `discoverQuerySaisie` retient la frappe sans 547 rien lancer ; `discoverQuery` reste ce qui a VRAIMENT été cherché. */''} 548 <input id="dec-recherche" type="search" value="${esc
548Html(STATE.discoverQuerySaisie != null ? STATE.discoverQuerySaisie : (STATE.discoverQuery || ''))}" placeholder="Chercher dans le fil" 549 class="bg-transparent border-0 outline-none text-[max(0.75rem,12px)]" style="color:var(--t-primary);width:150px" /> 550 ${STATE.discoverQuery ? `<button id="btn-dec-recherche-vider" title="Effacer la recherche" class="shrink-0" style="color:var(--t-faint);line-height:1">Ã</button>` : ''} 551 </div> 552 <button id="btn-discover-partager" class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium shrink-0" style="border:1px solid var(--border);color:var(--t-secondary)">Partager</button> 553 </div> 554 </div> 555 ${fil} 556 </div> 557 <!-- ââ LA COLONNE DE DROITE â UNE LIGNE VERTICALE DE TROIS BLOCS ââ 558 « Sur le côté, il reste des blancs en dessous de la ligne de 559 météo⦠il faudrait mettre d'autres blocs pour que tout ce blanc-là 560 soit rempli. Le bloc météo, on va faire une ligne verticale avec le 561 bloc météo et perspective marché et Entreprise, de la même hauteur, 562 une ligne de trois blocs mais en vertical, pas en horizontal â 563 comme ça les blocs pourront remplir toute la page. » 564 565 Trois choses portent ça, et aucune ne suffit seule : 566 md:h-[...dvh] donne enfin une hauteur à la colonne. Sans elle, 567 flex-1 ne partage rien : dans un conteneur de 568 hauteur automatique, il n'y a pas de reste a 569 distribuer, et les blocs gardent la taille de leur 570 contenu â c'est très exactement le blanc dont il 571 parle. 572 flex-1 sur chacun : la hauteur se partage en trois parts 573 égales, ce qui est la demande au mot près. 574 overflow-y-auto dans chaque bloc : un bloc trop rempli défile 575 chez lui au lieu de pousser les deux autres et de 576 défaire l'égalité. 577 578 dvh et non vh : sur mobile la barre d'adresse se rétracte, et 579 vh fige la hauteur d'avant la rétractation â la colonne dépasserait 580 de la page exactement de la hauteur de la barre. 581 582 Le bloc « Comment fonctionne Découvrir » a été retiré : c'était une 583 notice qu'on lit une fois et qui occupait ensuite le haut de la
584 colonne pour toujours. « Personnaliser » prend sa place â un geste 585 plutôt qu'une explication. --> 586 <aside class="w-full md:w-[300px] shrink-0 mt-6 md:mt-0 flex flex-col gap-3 md:sticky md:top-0 md:h-[calc(100dvh-118px)]"> 587 <button id="btn-discover-personnaliser" class="shrink-0 flex items-center justify-center gap-2 rounded-2xl py-2.5 text-[max(0.78125rem,12.5px)] font-semibold" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-primary)"> 588 ${_icon('gear', 'w-3.5 h-3.5')}Personnaliser${interests.length ? ` <span class="text-[max(0.6875rem,11px)] font-medium" style="color:var(--t-faint)">· ${interests.length}</span>` : ''} 589 </button> 590 <div class="flex-1 min-h-0 overflow-y-auto rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 591 <p class="text-[max(0.78125rem,12.5px)] font-bold mb-1" style="color:var(--t-primary)">${escHtml(weather?.location?.name || 'Météo')}</p> 592 ${weather?.current ? ` 593 <p class="text-[max(1.625rem,26px)] font-bold mb-2.5" style="color:var(--t-primary)">${Math.round(weather.current.temperature_2m)}°${escHtml(weather.units?.temperature_2m || 'C')}</p> 594 ${weather.daily?.time ? `<div class="flex gap-1.5">${weather.daily.time.map((t, i) => `<div class="flex-1 text-center rounded-lg py-2" style="background:var(--bg-input)"> 595 <p class="text-[max(0.5625rem,9px)]" style="color:var(--t-faint)">${escHtml(new Date(t).toLocaleDateString('fr-FR', { weekday: 'short' }))}</p> 596 <p class="text-[max(1rem,16px)]">${_weatherIcon(weather.daily.weather_code?.[i])}</p> 597 <p class="text-[max(0.59375rem,9.5px)]" style="color:var(--t-dim)">${Math.round(weather.daily.temperature_2m_max?.[i])}°</p> 598 </div>`).join('')}</div>` : ''} 599 ` : `<p class="text-[max(0.71875rem,11.5px)]" style="color:var(--t-dim)">${d.loading ? 'Relevé en coursâ¦' : 'Relevé indisponible pour le moment.'}</p>`} 600 </div> 601 <!-- « Perspectives du marché » et « Entreprises tendance » : les deux 602 encarts de la colonne de droite de la référence. Ils n'existaient 603 pas â la colonne s'arrêtait à la météo. --> 604 <div class="flex-1 min-h-0 overflow-y-auto rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 605 <p class="flex items-center gap-1 text-[max(0.78125rem,12.5px)] font-bold mb-2.5" style="color:var(--t-primary)">Perspectives du marché ${_icon('chevronRight', 'w-3 h-3')}</p> 606 <div class="grid grid-cols-2 gap-2">${_bourseBloc(d.indices, _bourseCarte, d.bourseError, d.loading && !(d.indices || []).length, 'col-span-2')}</div> 607 </div> 608 <div class="flex-1 min-h-0 overflow-y-auto rounded-2xl px-4 py-3" style="background:var(--bg-secondary);border:1px solid var(--border)"> 609 <p class="flex items-center gap-1 text-[max(0.78125rem,12.5px)] font-bold mb-1" style="color:var(--t-primary)">Entreprises tendance ${_icon('chevronRight', 'w-3 h-3')}</p> 610 ${_bourseBloc((d.entreprises || []).slice(0, 5), _bourseLigne, d.bourseError, d.loading && !(d.entreprises || []).length, 'py-2')} 611 </div> 612 </aside> 613 </div> 614 </div> 615 <!-- LA BARRE DE QUESTION DE DÃCOUVRIR â PROMISE QUATRE FOIS, CÃBLÃE NULLE PART. 616 Cet espace déclarait un chat en quatre endroits : trois suggestions dans 617 SPACE_SUGGESTIONS, un lieu dans STUDIOS, un profil 618 d'outils dans PROFILS_DE_SURFACE (recherche, actualités, lecture de page, 619 vidéo, images) et un appel à bindSpaceChat au démarrage. Aucun de ces 620 quatre n'a jamais servi : il n'y avait pas de champ, donc bindSpaceChat 621 sortait à la première ligne, faute de bouton d'envoi. 622 Un fil d'actualités est exactement l'endroit où l'on veut demander 623 « explique-moi ce sujet simplement » â et le profil pour y répondre en 624 allant LIRE était déjà écrit. Même gabarit que les cinq autres espaces. --> 625 <!-- BARRE DE QUESTION RETIRÃE ICI â DEMANDE DU PROPRIÃTAIRE, PROVISOIRE. 626 « Supprime la barre de questions de l'espace finance et découvrir, 627 eux ils viennent de masquer la moitié de l'écran. » 628 Le bloc épinglé en bas cumulait trois hauteurs : la grille de 629 suggestions, le fil de conversation (jusqu'à 38 % de la hauteur) et la 630 barre elle-même. Sur un écran de portable il ne restait presque rien 631 pour le contenu â or le contenu EST l'espace : l'actualité ici, les 632 marchés dans Finance. 633 Rien d'autre n'est démonté : le prompt système, le profil d'outils et 634 l'appel à bindSpaceChat restent en place et sortent proprement faute de 635 champ. Remettre les deux lignes ci-dessous suffit à tout réactiver. --> 636 </main>` 637} 638 639const FINANCE_TABS = [ 640 { id: 'marches', label: 'Marchés américains' }, 641 { id: 'crypto', label: 'Crypto' }, 642 { id: 'benefices', label: 'Bénéfices' }, 643 { id: 'predictions', label: 'Prédictions' }, 644 { id: 'screener', label: 'Screener' }, 645 { id: 'politiciens', label: 'Politiciens' }, 646 { id: 'liste-suivi', label: 'Liste de suivi' },
647 { id: 'galerie', label: "Galerie d'applications" }, 648] 649// Carte "Principaux actifs" â reprend le patron exact de la photo (nom, %, prix, 650// delta, graphique de tendance) avec de vraies données CoinGecko (crypto â pas 651// d'indices actions, cf. bandeau d'avertissement dans renderFinanceSpace). 652function _marketAssetCard(c) { 653 const pct = c.change24h || 0 654 const up = pct >= 0 655 const color = up ? '#16a34a' : '#dc2626' 656 // Delta en $ dérivé du prix + % réels (pas une valeur inventée séparément). 657 const prevPrice = c.price / (1 + pct / 100) 658 const deltaUsd = c.price - prevPrice 659 const digits = c.price < 10 ? 4 : 2 660 return `<div class="rounded-xl p-3.5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 661 <div class="flex items-center justify-between mb-1"> 662 <span class="text-[max(0.8125rem,13px)] font-semibold" style="color:var(--t-primary)">${escHtml(c.symbol)}</span> 663 <span class="text-[max(0.71875rem,11.5px)] font-semibold" style="color:${color}">${up ? 'â' : 'â'} ${Math.abs(pct).toFixed(2)} %</span> 664 </div> 665 <p class="text-[max(0.9375rem,15px)] font-bold mb-0.5" style="color:var(--t-primary)">${c.price.toLocaleString('fr-FR', { maximumFractionDigits: digits })} $US</p> 666 <p class="text-[max(0.65625rem,10.5px)] mb-1.5" style="color:${color}">${up ? '+' : '-'}${Math.abs(deltaUsd).toLocaleString('fr-FR', { maximumFractionDigits: digits })} $US</p> 667 <div style="height:32px">${_sparklineSvg(c.sparkline, color)}</div> 668 </div>` 669} 670// Ligne "Créer une liste de suivi" â même patron que les lignes Amazon/Apple/Tesla 671// de la photo (avatar, nom, prix, %), avec de vrais actifs crypto (seuls disponibles 672// sans clé de fournisseur de données actions). 673function _watchlistRow(c) { 674 const up = (c.change24h || 0) >= 0 675 return `<div class="flex items-center gap-2.5 py-2" style="border-bottom:1px solid var(--border)"> 676 <span class="w-7 h-7 rounded-full flex items-center justify-center text-[max(0.625rem,10px)] font-bold text-white shrink-0" style="background:linear-gradient(135deg,#f59e0b,#ea580c)">${escHtml(c.symbol.slice(0, 3))}</span> 677 <span class="flex-1 min-w-0"> 678 <span class="block text-[max(0.75rem,12px)] font-medium truncate" style="color:var(--t-primary)">${escHtml(c.symbol)}</span> 679 <span class="block text-[max(0.625rem,10px)]" style="color:var(--t-faint)">${escHtml(c.id)} · Crypto</span> 680 </span> 681 <span class="text-right shrink-0"> 682 <span class="block text-[max(0.75rem,12px)] font-medium" style="color:var(--t-primary)">${c.price.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} $US</span> 683 <span class="block text-[max(0.65625rem,10.5px)]" style="color:${up ? '#16a34a' : '#dc2626'}">${up ? '+' : ''}${(c.change24h || 0).toFixed(2)} %</span> 684 </span> 685 <button data-watchlist-star="${escHtml(c.id)}" class="shrink-0" style="color:var(--t-faint)" title="Suivre">${_icon('star', 'w-3.5 h-3.5')}</button> 686 </div>` 687} 688function renderFinanceSpace() { 689 const d = (STATE.spaceData || {}).espaceFinance || {} 690 const tab = STATE.financeTab || 'marches' 691 const real = tab === 'marches' || tab === 'crypto' 692 const marketSummary = (d.news || [])[0] 693 return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell"> 694 ${_spaceMenuBtn()} 695 <div class="flex-1 overflow-y-auto min-h-0"> 696 <div class="w-full px-4 md:px-8 pt-3 pb-4 md:flex md:gap-8"> 697 <div class="flex-1 min-w-0"> 698 ${_spaceTitre('Finance', 'text-[max(0.9375rem,15px)] font-semibold', 'color:var(--t-primary)', { marge: 'mb-3' })} 699 <div class="flex gap-2 overflow-x-auto pb-3 mb-3" style="scrollbar-width:none">${FINANCE_TABS.map(t => `<button data-finance-tab="${t.id}" class="shrink-0 rounded-full px-3.5 py-1.5 text-[max(0.75rem,12px)] font-medium" style="${tab === t.id ? 'background:var(--accent);color:white' : 'background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)'}">${t.label}</button>`).join('')}</div> 700 <!-- Le corps n'est plus conditionné au voyant global : chaque bloc 701 s'affiche dès QUE SA source a répondu. Tout masquer tant qu'une 702 seule source traînait faisait attendre quinze secondes des 703 cotations déjà arrivées en une demi-seconde. --> 704 ${real ? ` 705 <!-- Le bandeau « les indices actions demandent une clé de 706 fournisseur, pas encore configurée » a disparu : il est devenu 707 faux. /api/bourse rend indices, entreprises et secteurs. --> 708 <div class="flex items-center justify-between mb-2"> 709 <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide" style="color:var(--t-faint)">${tab === 'crypto' ? 'Cryptomonnaies' : 'Principaux actifs'}</p> 710 ${(() => { 711 const list = tab === 'crypto' ? (d.crypto || []).map(c => ({ variation: c.change24h })) : (d.indices || []) 712 const chiffres = list.map(x => x.variation).filter(Number.isFinite) 713 if (!chiffres.length) return '' 714 const moy = chiffres.reduce((a, b) => a + b, 0) / chiffres.length 715 return `<span class="text-[max(0.6875rem,11px)] font-medium" style="color:${moy >= 0 ? '#16a34a' : '#dc2626'}">Sentiment ${moy >= 0 ? 'haussier' : 'baissier'}</span>` 716 })()} 717 </div> 718 <div class="grid grid-cols-2 gap-3 mb-6">${tab === 'crypto' 719 ? _bourseBloc((d.crypto || []).slice(0, 6), _marketAssetCard, d.marketsError, d.loading && !(d.crypto || []).length, 'col
719-span-2') 720 : _bourseBloc(d.indices, _bourseCarte, d.bourseError, d.loading && !(d.indices || []).length, 'col-span-2')}</div> 721 ${tab !== 'crypto' ? ` 722 <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide mb-2" style="color:var(--t-faint)">Entreprises tendance</p> 723 <div class="rounded-2xl px-3 mb-6" style="background:var(--bg-secondary);border:1px solid var(--border)"> 724 ${_bourseBloc(d.entreprises, _bourseLigne, d.bourseError, d.loading && !(d.entreprises || []).length, 'py-3')} 725 </div> 726 <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide mb-2" style="color:var(--t-faint)">Secteurs d'actions</p> 727 <div class="rounded-2xl px-3.5 py-2 mb-6" style="background:var(--bg-secondary);border:1px solid var(--border)"> 728 ${_bourseBloc(d.secteurs, _bourseSecteur, d.bourseError, d.loading && !(d.secteurs || []).length, 'py-3')} 729 </div>` : ''} 730 <div class="flex items-center justify-between mb-2"> 731 <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide" style="color:var(--t-faint)">Résumé du marché</p> 732 ${d.at ? `<span class="text-[max(0.65625rem,10.5px)]" style="color:var(--t-faint)">Mis à jour ${escHtml(relTime(d.at))}</span>` : ''} 733 </div> 734 ${marketSummary ? `<a href="${escHtml(marketSummary.url)}" target="_blank" rel="noopener" class="block rounded-xl p-3.5 mb-2" style="background:var(--bg-secondary);border:1px solid var(--border)"> 735 <p class="text-[max(0.84375rem,13.5px)] font-bold mb-1 leading-snug" style="color:var(--t-primary)">${escHtml(marketSummary.title)}</p> 736 <p class="text-[max(0.6875rem,11px)]" style="color:var(--t-faint)">${escHtml(marketSummary.source || '')}</p> 737 </a>` : (d.newsError 738 ? `<p class="text-[max(0.75rem,12px)]" style="color:var(--t-dim)">Actualités indisponibles â ${escHtml(d.newsError)}. <button onclick="window.rechargerEspace('${escHtml(STATE.workMode)}')" style="color:var(--accent);text-decoration:underline">Réessayer</button></p>` 739 : `<p class="text-[max(0.75rem,12px)]" style="color:var(--t-dim)">Aucune actualité pour l'instant.</p>`)} 740 ` : ''} 741 ${!real ? `<div class="text-center py-16"><p class="text-[max(0.8125rem,13px)]" style="color:var(--t-dim)">${escHtml(FINANCE_TABS.find(t => t.id === tab)?.label || '')} â bientôt disponible.</p></div>` : ''} 742 </div> 743 <aside class="w-full md:w-[260px] shrink-0 mt-6 md:mt-0"> 744 <p class="flex items-center gap-1.5 text-[max(0.75rem,12px)] font-bold uppercase tracking-wide mb-2" style="color:var(--t-faint)">${_icon('star', 'w-3.5 h-3.5')}Créer une liste de suivi</p> 745 <div class="rounded-2xl px-3" style="background:var(--bg-secondary);border:1px solid var(--border)"> 746 ${_bourseBloc(d.crypto, _watchlistRow, d.marketsError, d.loading && !(d.crypto || []).length, 'py-3')} 747 </div> 748 </aside> 749 </div> 750 </div> 751 <!-- BARRE DE QUESTION RETIRÃE ICI â même raison que dans Découvrir, et même 752 réversibilité : ces deux lignes remises, l'espace retrouve son chat. 753 CFO Personnel garde la sienne : c'est un espace de conseil où la
754 question EST le contenu, pas un tableau de marchés qu'elle recouvre. --> 755 </main>` 756} 757 758const CFO_TABS = [{ id: 'total', label: 'Total' }, { id: 'investissements', label: 'Investissements' }, { id: 'depenses', label: 'Dépenses' }, { id: 'passifs', label: 'Passifs' }] 759// LES COMPTES RÃELS, OU LA RAISON DE LEUR ABSENCE. 760// 761// L'écran disait « Aucun compte relié » sans jamais proposer d'en relier un, et 762// sans distinguer « pas encore branché chez l'hébergeur » de « aucun compte 763// connecté ». Deux situations très différentes : la première se règle avec une 764// clé, la seconde avec un clic. 765function _cfoComptes(d, tab) { 766 // Les opérations arrivent par un appel séparé, pour ne pas attendre les 767 // comptes : elles sont rangées à côté, pas dedans. 768 const b = { ...(d.banque || {}), operations: d.banqueOps || [] } 769 const cadre = (dedans) => `<div class="rounded-2xl p-8 text-center" style="background:var(--bg-secondary);border:1px dashed var(--border)">${dedans}</div>` 770 771 if (b.configure === false) { 772 return cadre(`<p class="mb-2" style="color:var(--t-faint)">${_icon('briefcase', 'w-8 h-8 mx-auto')}</p> 773 <p class="text-[max(0.84375rem,13.5px)] font-semibold mb-1" style="color:var(--t-primary)">Connexion bancaire pas encore activée</p> 774 <p class="text-[max(0.75rem,12px)] max-w-sm mx-auto" style="color:var(--t-dim)">L'agrégateur n'est pas configuré sur ce serveur. Une fois la clé posée, vos comptes se relient en un clic â vos identifiants bancaires restent chez votre banque, Créa ne les voit jamais.</p>`) 775 } 776 if (!(b.comptes || []).length) { 777 return cadre(`<p class="mb-2" style="color:var(--t-faint)">${_icon('briefcase', 'w-8 h-8 mx-auto')}</p> 778 <p class="text-[max(0.84375rem,13.5px)] font-semibold mb-1" style="color:var(--t-primary)">Aucun compte relié pour l'instant</p> 779 <p class="text-[max(0.75rem,12px)] max-w-sm mx-auto mb-3" style="color:var(--t-dim)">Reliez votre banque pour voir ici vos ${tab === 'investissements' ? 'investissements' : tab === 'depenses' ? 'dépenses' : tab === 'passifs' ? 'passifs' : 'finances'}. <strong>Vous vous identifiez chez votre banque</strong>, jamais dans Créa.</p> 780 <button onclick="window.cfoRelierBanque()" class="rounded-full px-4 py-2 text-[max(0.78125rem,12.5px)] font-semibold" style="background:var(--accent);color:#fff;border:none">${b.ouverture ? 'Ouvertureâ¦' : 'Relier ma banque'}</button> 781 ${b.lien ? `<p class="text-[max(0.75rem,12px)] mt-3" style="color:var(--t-dim)">Votre navigateur a bloqué la fenêtre. <a href="${escHtml(b.lien)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent);font-weight:600">Ouvrir la page de ma banque</a></p>` : ''} 782 ${d.banqueRelecture ? '<p class="text-[max(0.75rem,12px)] mt-3" style="color:var(--t-dim)">Lecture de vos comptesâ¦</p>' 783 : window._cfoGuetActif ? `<p class="text-[max(0.75rem,12px)] mt-3" style="color:var(--t-dim)">Terminez la connexion dans l'autre fenêtre. Vos comptes apparaîtront ici dès votre retour â <button onclick="window.cfoRafraichirBanque()" style="background:none;border:none;padding:0;color:var(--accent);font-weight:600;cursor:pointer">actualiser maintenant</button>.</p>` : ''} 784 ${b.raison ? `<p class="text-[max(0.71875rem,11.5px)] mt-3" style="color:#ef4444">${escHtml(b.raison)}</p>` : ''}`) 785 } 786 787 const somme = b.total == null 788 // Plusieurs devises : pas de total. Un nombre qui additionne des euros et 789 // des dollars ne veut rien dire, et l'afficher lui donnerait l'air d'en 790 // vouloir un. 791 ? '<span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">plusieurs devises â pas de total</span>' 792 : `${b.total.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${escHtml(b.devise || 'EUR')}` 793 return `<div class="rounded-2xl p-4 mb-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 794 <p class="text-[max(0.71875rem,11.5px)] mb-1" style="color:var(--t-faint)">Total</p> 795 <p class="text-[max(1.625rem,26px)] font-bold leading-none mb-3" style="color:var(--t-primary)">${somme}</p> 796 <div class="grid gap-2" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))"> 797 ${b.comptes.map(c => `<div class="rounded-xl p-3" style="background:var(--bg-input);border:1px solid var(--border)"> 798 <p class="text-[max(0.78125rem,12.5px)] font-semibold" style="color:var(--t-primary)">${escHtml(c.nom)}</p> 799 <p class="text-[max(0.6875rem,11px)] mb-1.5" style="color:var(--t-faint)">${escHtml(c.banque)}${c.iban ? ' · â¢â¢â¢â¢' + escHtml(c.iban) : ''}</p> 800 <p class="text-[max(0.9375rem,15px)] font-bold" style="color:var(--t-primary)">
800${(c.solde ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${escHtml(c.devise)}</p> 801 </div>`).join('')} 802 </div> 803 <button onclick="window.cfoRelierBanque()" class="mt-3 text-[max(0.71875rem,11.5px)] font-medium" style="background:none;border:none;color:var(--accent);cursor:pointer">+ Relier une autre banque</button> 804 ${(d.banqueConnexions || []).length ? `<div class="mt-3 pt-3" style="border-top:1px solid var(--border)"> 805 <p class="text-[max(0.6875rem,11px)] font-bold uppercase tracking-wide mb-1.5" style="color:var(--t-faint)">Banques reliées</p> 806 ${d.banqueConnexions.map(c => `<div class="flex items-center justify-between py-1.5"> 807 <span class="text-[max(0.75rem,12px)] min-w-0" style="color:var(--t-secondary)">${escHtml(c.banque)}${c.aRenouveler ? ' <span style="color:#d97706">· connexion à renouveler</span>' : ''}</span> 808 <button onclick="window.cfoDelierBanque('${escHtml(c.id)}', '${escHtml(c.banque).replace(/'/g, '')}')" class="shrink-0 ml-3 text-[max(0.71875rem,11.5px)] font-medium" style="background:none;border:none;color:#ef4444;cursor:pointer">Délier</button> 809 </div>`).join('')} 810 </div>` : ''} 811 </div> 812 ${(b.operations || []).length ? `<p class="text-[max(0.78125rem,12.5px)] font-bold mb-2" style="color:var(--t-primary)">Dernières opérations</p> 813 <div class="flex flex-col gap-1">${b.operations.slice(0, 25).map(o => `<div class="flex items-center justify-between rounded-lg px-3 py-2 text-[max(0.78125rem,12.5px)]" style="background:var(--bg-secondary)"> 814 <span style="color:var(--t-secondary)">${escHtml(o.libelle)}${o.enAttente ? ' <span style="color:var(--t-faint)">· à venir</span>' : ''}</span> 815 <span class="font-semibold shrink-0 ml-3" style="color:${o.montant < 0 ? 'var(--t-primary)' : '#059669'}">${(o.montant ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${escHtml(o.devise)}</span> 816 </div>`).join('')}</div>` : ''}` 817} 818 819// Ouvre la page de l'agrégateur. C'est Là que la personne s'identifie. 820// 821// EN VRAIE FENÃTRE, PAS DANS LE NAVIGATEUR INTERNE DE CRÃA â et c'est la seule 822// exception assumée à la règle « tout reste dans Créa ». Deux raisons, dans cet 823// ordre : 824// 825// 1. Bridge REFUSE d'être affiché dans un cadre. Vérifié sur la vraie page : 826// `x-frame-options: SAMEORIGIN` et `frame-ancestors 'self'`. Le navigateur 827// interne de Créa est un cadre : la page y restait blanche, et le bouton 828// paraissait ne rien faire. C'était le défaut. 829// 2. Et ce refus est SAIN. Une page où l'on saisit ses identifiants bancaires 830// doit être vue dans une vraie fenêtre, avec son adresse et son cadenas 831// lisibles. L'afficher dans un cadre est exactement ce que fait une page 832// d'hameçonnage. On ne contourne pas cette protection, on la respecte. 833window.cfoRelierBanque = async function () { 834 STATE.spaceData ||= {} 835 const maj = (m) => { STATE.spaceData.espaceCfo = { ...(STATE.spaceData.espaceCfo || {}), banque: { ...((STATE.spaceData.espaceCfo || {}).banque || {}), ...m } }; render() } 836 maj({ ouverture: true, raison: '' }) 837 const jeton = (typeof backendToken === 'function' && backendToken()) || '' 838 try { 839 const r = await fetch('/api/banque/session', { 840 method: 'POST', credentials: 'include', 841 headers: { 'Content-Type': 'application/json', ...(jeton ? { Authorization: 'Bearer ' + jeton } : {}) }, 842 body: '{}', 843 }) 844 // Une session expirée rend 401 avec un corps qui n'a PAS la forme 845 // {ok, raison} : sans ce cas, l'écran affichait « raison inconnue » là où 846 // il suffisait de se reconnecter. 847 if (r.status === 401 || r.status === 403) { 848 maj({ ouverture: false, raison: 'Votre session a expiré. Reconnectez-vous, puis réessayez.' }) 849 return 850 } 851 const d = await r.json().catch(() => null) 852 if (!d || !d.ok) { maj({ ouverture: false, raison: _cfoMotif(d && d.raison) }); return } 853 854 // Vraie fenêtre. `noopener` : la page ouverte ne doit pas garder la main 855 // sur celle de Créa. 856 const fenetre = window.open(d.url, '_blank', 'noopener,noreferrer') 857 // Armé AVANT même de savoir si la fenêtre s'est ouverte : que la personne 858 // passe par la fenêtre ou par le lien de repli, son retour doit relire les 859 // comptes. 860 _cfoGuetterRetour() 861 // Bloquée par le navigateur â le cas est fréquent et il était muet. On 862 // garde l'adresse et l'écran propose un vrai lien à cliquer : c'est un 863 // geste délibéré de la personne, que plus aucun bloqueur n'intercepte. 864 maj({ ouverture: false, lien: fenetre ? '' : d.url, raison: '' }) 865 } catch (e) { 866 maj({ ouverture: false, raison: 'Connexion impossible : ' + e.message }) 867 } 868} 869/** 870 * RELIRE LES COMPTES â le bouton « actualiser maintenant », et le retour. 871 * 872 * Cette fonction était APPELÃE à deux endroits et DÃFINIE nulle part : 873 * `_cfoGuetterRetour` la déclenche au retour de la page de la banque, et 874 * l'écran offre un bouton qui la nomme. Les deux levaient une erreur dans la 875 * console, et le geste ne faisait rien â ce qui est exactement le défaut que 876 * le guet existait pour éviter : « il faudrait recharger la page à la main 877 * pour voir ses comptes, et personne ne devine qu'il faut le faire ». 878 * 879 * On relit TOUT l'espace, avec `force` : les comptes, les opérations et les 880 * connexions viennent de trois routes, et une seule relue laisserait l'écran 881 * mi-frais mi-périmé. 882 */ 883window.cfoRafraichirBanque = async function () { 884 STATE.spaceData ||= {} 885 STATE.spaceData.espaceCfo = { ...(STATE.spaceData.espaceCfo || {}), banqueRelecture: true } 886 render() 887 try { 888 if (typeof _loadSpaceData === 'function') await _loadSpaceData('espaceCfo', true) 889 } finally { 890 STATE.spaceData.espaceCfo = { ...(STATE.spaceData.espaceCfo || {}), banqueRelecture: false } 891 render() 892 } 893} 894 895/** 896 * DÃLIER UNE BANQUE â « relier sans pouvoir délier, c'est retenir un 897 * consentement qu'on doit pouvoir reprendre ». 898 * 899 * La route existait côté serveur, le bouton existait à l'écran, et la fonction 900 * entre les deux n'a jamais été écrite : cliquer « Délier » ne faisait rien. 901 * 902 * ON DEMANDE CONFIRMATION, et pas par politesse : la déliaison est faite chez 903 * l'agrégateur, elle est irréversible, et il faut repasser par la banque pour 904 * revenir. Un clic malheureux dans une liste coûterait toute une reconnexion. 905 */ 906window.cfoDelierBanque = async function (id, nom) { 907 const quelle = String(nom || 'cette banque') 908 if (!confirm(`Délier ${quelle} ?\n\nCréa n'aura plus accès à ces comptes. Pour revenir, il faudra vous identifier à nouveau chez votre banque.`)) return 909 const jeton = (typeof backendToken === 'function' && backendToken()) || '' 910 try { 911 const r = await fetch('/api/banque/connexion?id=' + encodeURIComponent(String(id || '')), { 912 method: 'DELETE', 913 credentials: 'include', 914 headers: jeton ? { Authorization: 'Bearer ' + jeton } : {}, 915 }) 916 const d = await r.json().catch(() => null) 917 if (!d || !d.ok) { 918 STATE.spaceData ||= {} 919 STATE.spaceData.espaceCfo = { 920 ...(STATE.spaceData.espaceCfo || {}), 921 banque: { ...((STATE.spaceData.espaceCfo || {}).banque || {}), raison: _cfoMotif(d && d.raison) }, 922 } 923 render() 924 return 925 } 926 } catch (e) { 927 STATE.spaceData ||= {} 928 STATE.spaceData.espaceCfo = { 929 ...(STATE.spaceData.espaceCfo || {}), 930 banque: { ...((STATE.spaceData.espaceCfo || {}).banque || {}), raison: 'Déliaison impossible : ' + e.message }, 931 } 932 render() 933 return 934 } 935 await window.cfoRafraichirBanque() 936} 937 938function _cfoMotif(code) { 939 if (code === 'agregateur-non-configure') return 'La connexion bancaire n\'est pas encore activée sur ce serveur.' 940 if (code === 'session-sans-url') return 'L\'agrégateur n\'a pas rendu d\'adresse de connexion.' 941 return 'Connexion impossible : ' + String(code || 'raison inconnue') 942} 943 944function renderCfoSpace() { 945 const d = (STATE.spaceData || {}).espaceCfo || {} 946 const tab = STATE.cfoTab || 'total' 947 return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell"> 948 ${_spaceMenuBtn()} 949 <div class="flex-1 overflow-y-auto min-h-0"> 950 <div class="w-full px-4 md:px-8 pt-3 pb-4 md:flex md:gap-8"> 951 <div class="flex-1 min-w-0"> 952 <div class="flex items-center justify-between mb-3"> 953 ${_spaceTitre('Directeur financier personnel', 'text-[max(0.8125rem,13px)]', 'color:var(--t-dim)', { avant: _icon('eye', 'w-4 h-4') })} 954 <button class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Partager</button> 955 </div> 956 <div class="flex gap-4 mb-5">${CFO_TABS.map(t => `<button data-cfo-tab="${t.id}" class="text-[max(0.78125rem,12.5px)] font-medium pb-1.5" style="${tab === t.id ? 'border-bottom:2px solid var(--accent);color:var(--t-primary)' : 'color:var(--t-dim)'}">${t.label}</button>`).join('')}</div> 957 ${_cfoComptes(d, tab)} 958 </div> 959 <aside class="w-full md:w-[280px] shrink-0 mt-6 md:mt-0"> 960 <p class="text-[max(0.78125rem,12.5px)] font-bold mb-2.5" style="color:var(--t-primary)">Explorez vos finances</p> 961 <div class="flex flex-wrap gap-1.5 mb-4"> 962 ${[[_icon('wallet'), 'Dépenses'], [_icon('card'), 'Abonnements'], [_icon('chart'), 'Portefeuille']].map(([ic, lb]) => `<button data-cfo-quick="${escHtml(lb)}" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[max(0.6875rem,11px)] font-medium" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${ic}${lb}</button>`).join('')} 963 </div>
964 <button data-cfo-quick="Top spending categories this month" class="flex items-center gap-1.5 mb-2 text-[max(0.71875rem,11.5px)] font-medium" style="color:var(--accent)">${_icon('chevronRight', 'w-3.5 h-3.5')}Catégories de dépenses du mois</button> 965 <button data-cfo-quick="Posez votre propre question" class="flex items-center gap-1.5 mb-4 text-[max(0.71875rem,11.5px)] font-medium" style="color:var(--accent)">${_icon('computer', 'w-3.5 h-3.5')}Posez votre propre question</button> 966 <div class="flex items-center gap-1.5 mb-2"> 967 <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide" style="color:var(--t-faint)">Perspectives du marché</p> 968 ${_icon('chevronRight', 'w-3 h-3')} 969 </div> 970 <div class="grid grid-cols-2 gap-2 mb-5"> 971 ${_bourseBloc(d.indices, _bourseCarte, d.bourseError, d.loading && !(d.indices || []).length, 'col-span-2')} 972 </div> 973 <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide mb-2" style="color:var(--t-faint)">Actualité économique</p> 974 <div class="space-y-2">${(d.news || []).slice(0, 3).map(a => `<a href="${escHtml(a.url)}" target="_blank" rel="noopener" class="block rounded-xl p-2.5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 975 <p class="text-[max(0.71875rem,11.5px)] font-medium leading-snug" style="color:var(--t-primary)">${escHtml(a.title)}</p> 976 </a>`).join('') || '<p class="text-[max(0.71875rem,11.5px)]" style="color:var(--t-dim)">Indisponible.</p>'}</div> 977 </aside> 978 </div> 979 </div> 980 <!-- ââ LA BARRE DE QUESTIONS EST PARTIE, COMME CHEZ DÃCOUVRIR ET FINANCE ââ 981 « Dans directeur financier personnel, tu as mis une barre de questions 982 en plein milieu, il faut la supprimer. » 983 984 MESURÃ, sur une hauteur de portable (813 px) : le bloc « suggestions 985 pour vous » + la barre occupaient 300 px â 41 % de l'écran â dans un 986 espace dont le contenu EST le sujet. C'est le même défaut, et la même 987 proportion, que celui qui avait déjà fait retirer la barre de Découvrir 988 et de Finance. 989 990 LES QUATRE AUTRES DÃCLARATIONS RESTENT (suggestions, prompt système, 991 profil d'outils, inscription à la relance) : le rétablissement tient en 992 deux lignes, et boucle-partout-smoke vérifie qu'il n'en reste pas un 993 morceau à moitié câblé â un champ sans bouton prendrait la place sans 994 rien faire. Rétablir = retirer espaceCfo de SANS_BARRE_POUR_L_INSTANT 995 et remettre les deux lignes ci-dessous. --> 996 </main>` 997} 998 999/* CINQ PASTILLES EN ANGLAIS DANS UN PRODUIT FRANÃAIS. Leur libellé n'est pas 1000 qu'un libellé : il PART comme requête de recherche (`_runSpaceSearch(sid, 1001 label + â¦)`). « Humanities » cherchait donc en anglais, dans un espace dont 1002 tout le reste â titre, invite, sujets tendance â est en français. */ 1003const EDUCATION_PILLS = [{ label: 'Santé', icon: _icon('health') }, { label: 'Droit', icon: _icon('law') }, { label: 'Technologie', icon: _icon('computer') }, { label: 'Sciences', icon: _icon('science') }, { label: 'Sciences humaines', icon: _icon('humanities') }] 1004const EDUCATION_TOPICS = [ 1005 { c1: '#2563eb', c2: '#7c3aed', q: 'dernières avancées technologie et sciences', label: 'Technologie & sciences', img: 'abstract digital illustration, glowing blue and orange particle network explosion, futuristic technology editorial art', seed: 11 }, 1006 { c1: '#f97316', c2: '#dc2626', q: 'actualité sciences politiques et économie', label: 'Sciences politiques & économie', img: 'abstract editorial illustration, two hands reaching toward each other, geometric shapes, orange and dark red color palette', seed: 12 }, 1007 { c1: '#eab308', c2: '#f59e0b', q: 'recherche académique finance et marchés', label: 'Finance académique', img: 'abstract editorial illustration, stacked gold coins, geometric triangle shapes, blue and gold color palette', seed: 13 }, 1008] 1009function renderEducationSpace() { 1010 const d = (STATE.spaceSearch || {}).espaceEducation || {} 1011 return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell"> 1012 ${_spaceMenuBtn()} 1013 <div class="flex-1 overflow-y-auto min-h-0"> 1014 <div class="max-w-3xl mx-auto px-4 pt-12 pb-6"> 1015 ${_spaceTitre('Ãducation', 'text-[max(2.375rem,38px)] font-bold', 'color:var(--t-primary)', { centre: true, marge: 'mb-8', chevron: 'w-6 h-6' })} 1016 <div class="mb-4">${renderUniversalInputBar({ inputId: 'space-input-espaceEducation', sendId: 'btn-space-send-espaceEducation', micId: 'btn-space-mic-espaceEducation', placeholder: 'Explorer des articles académiques, des revues et plus encore', rows: 1 })}</div> 1017 ${_spaceChatArea('espaceEducation', SPACE_SUGGESTIONS.espaceEducation, true)} 1018 <div class="flex gap-2.5 overflow-x-auto pb-5" style="scrollbar-width:none">${EDUCATION_PILLS.map(p => `<button data-space-pill-espaceEducation="${escHtml(p.label)}" class="shrink-0 inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-[max(0.875rem,14px)] font-medium" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">${p.icon}${p.label}</button>`).join('')}</div> 1019 ${d.loading ? `<p class="text-center py-8 text-[max(0.875rem,14px)]" style="color:var(--t-dim)">Rechercheâ¦</p>` : 1020 d.results ? _searchResultsList(d.results, 'Aucun résultat.') : ` 1021 <div class="flex items-center justify-between mb-4"> 1022 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Sujets tendance</p> 1023 <button data-space-scroll="edu-topics" style="color:var(--t-faint)">${_icon('chevronRight', 'w-5 h-5')}</button> 1024 </div> 1025 <div id="edu-topics" class="flex gap-4 overflow-x-auto pb-2" style="scrollbar-width:none">${EDUCATION_TOPICS.map(t => _spaceImageCard(t.img, t.label, t.c1, t.c2, `data-space-topic-espaceEducation="${escHtml(t.q)}"`, t.seed)).join('')}</div> 1026 `} 1027 </div> 1028 </div> 1029 </main>` 1030} 1031 1032/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1033 * CRÃA ACADÃMIE â DEUX FAÃONS D'APPRENDRE, ET ELLES NE SE RESSEMBLENT PAS 1034 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1035 * 1036 * « On va créer Créa Académie, et on va la séparer en sections : d'un côté la 1037 * partie tutoriel, de l'autre la partie éducation. Ãa existait déjà , c'était 1038 * juste mal structuré â c'est le studio éducation. » 1039 * 1040 * ââ CE QUI EXISTAIT, ET POURQUOI ON NE LE TROUVAIT PAS ââââââââââââââââââââ 1041 * 1042 * APPRENDRE UNE MATIÃRE existait, et à TROIS endroits qui ne se parlaient pas : 1043 * 1044 * · `renderEducationSpace()` â une recherche académique, cinq pastilles 1045 * écrites en anglais dans un produit français, trois sujets tendance. 1046 * Aucun lien vers la suite ; 1047 * · le Studio Ãducation (le tronc) â quatorze matières, leur
1047s thèmes, 1048 * quatre modes de séance, un score qui se tient. Joignable seulement par 1049 * la liste des studios, entre « Marketing » et « Data ». Il est 1050 * SUPPRIMà depuis : le programme est ici, une seule fois ; 1051 * · `STUDIOS.edu` â le lieu et ses contrats de sortie, qui ne montaient 1052 * jamais dans la séance : elle écrivait sa propre professeure à la main. 1053 * 1054 * Trois portes pour une chose, et aucune qui mène aux deux autres. 1055 * 1056 * APPRENDRE CRÃA n'existait nulle part. Zéro tutoriel dans le dépôt : on 1057 * découvrait les quatorze studios en cliquant dessus. `grep -rn "tutoriel" 1058 * src/` ne rendait que des expressions régulières qui REPÃRENT le mot dans 1059 * une demande. 1060 * 1061 * ââ LA FORME VIENT DES CAPTURES, PAS DE MOI âââââââââââââââââââââââââââââââ 1062 * 1063 * Quatre rangées, dans cet ordre, et une page par rôle : 1064 * 1065 * Cours en vedette des parcours en plusieurs leçons 1066 * Prise en main de courts tutoriels, quelques minutes chacun 1067 * Parcourir par rôle onze métiers, chacun sa page 1068 * Planifier un bilan une séance récurrente, posée dans le chat 1069 * 1070 * page d'un rôle â Essayer un prompt · Cas d'usage 1071 * 1072 * ââ UN PROMPT S'OUVRE EN BROUILLON, IL NE PART PAS ââââââââââââââââââââââââ 1073 * 1074 * Chaque carte de prompt porte `data-studio-template-card` et 1075 * `data-template-exemple` : c'est le geste QUI EXISTE DÃJà (voir `bindEvents`, 1076 * autour de `studioLancerModele`). Il ouvre le lieu visé, crée le projet de 1077 * code s'il en manque un, ouvre la matière que vise un modèle Ãducation, et 1078 * ÃCRIT le texte dans la barre sans l'envoyer. Poser ici un second 1079 * gestionnaire aurait donné deux comportements pour un seul geste â le canal 1080 * parallèle que ce dépôt passe son temps à défaire. 1081 * 1082 * ââ CE QUE CETTE ACADÃMIE EST, ET CE QU'ELLE N'EST PAS ââââââââââââââââââââ 1083 * 1084 * C'est UNE PORTE, pas un quatrième contenu. Elle ne recopie ni les matières 1085 * (elle a son propre programme, `src/crea-ecole.js`, depuis que la table du 1086 * studio est partie avec lui), ni le métier d'un lieu (elle renvoie au lieu, 1087 * qui porte déjà le sien). Et elle ne se donne PAS d'identité 1088 * (règle 1) : son prompt dit d'Oà elle parle, jamais QUI elle est. 1089 * 1090 * ââ LE MODE D'ÃCHEC QU'ELLE POURRAIT AVOIR ââââââââââââââââââââââââââââââââ 1091 * 1092 * Une carte qui n'ouvre rien. `openModeTab()` d'un mode inconnu SORT à SA 1093 * PREMIÃRE LIGNE (`if (!m) return`) â pas d'erreur, pas de message, un clic 1094 * mort ; `data-studio-template-card` a le même silence 1095 * (`if (!mode || !exemple) return`). `tests/academie-smoke.mjs` tient chaque 1096 * cible : tout lieu nommé ici est un mode que `render()` sait dessiner. 1097 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1098 1099const ACADEMIE_SECTIONS = [ 1100 { id: 'tutoriels', label: 'Tutoriels', quoi: 'Se servir de CREA' }, 1101 { id: 'education', label: 'Ãcole', quoi: 'Apprendre une matière' }, 1102] 1103 1104/* ââ LES COURS EN VEDETTE ET LES QUATRE FICHES SONT PARTIS AVEC CE QU'ILS 1105 * DOUBLAIENT âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1106 * 1107 * `ACADEMIE_PARCOURS` annonçait deux « cours » de quatre leçons écrites en une 1108 * ligne chacune, et `ACADEMIE_TUTOS` quatre fiches à plat. Les deux vivaient 1109 * du temps où l'Académie n'avait rien d'autre. 1110 * 1111 * Aujourd'hui l'Ãcole porte 188 cours et 1 139 leçons, et les tutoriels ont 1112 * leur propre colonne â 8 domaines, 14 parcours, 25 tutoriels, 82 étapes 1113 * (src/crea-tutoriels.js). Garder les six anciennes cartes à côté, c'était 1114 * proposer deux fois la même chose à deux profondeurs différentes, et laisser 1115 * la moins bonne en vitrine. 1116 * 1117 * Le catalogue lit maintenant la vraie table : ses lignes « tutoriels » sont 1118 * les 25 tutoriels, désignés par leur clé `parcours/tutoriel` â celle que 1119 * l'écran du tutoriel relit, pas une seconde forme d'identifiant. 1120 */ 1121 1122/* ââ PARCOURIR PAR RÃLE âââââââââââââââââââââââââââââââââââââââââââââââââââ 1123 * Chaque rôle a SA page : des prompts qui s'ouvrent en brouillon là où ils 1124 * servent, et des cas d'usage qui disent le chemin étape par étape. 1125 * `ou` est un lieu que `render()` sait dessiner â la garde le vérifie. */ 1126const ACADEMIE_ROLES = [ 1127 { id: 'marketing', label: 'Marketing', icone: 'megaphone', 1128 prompts: [ 1129 { ou: 'marketing', titre: 'Ãcrire un post LinkedIn', quoi: "Un post qui tient en 150 à 300 mots, avec un seul appel à l'action.", exemple: "Rédige un post LinkedIn qui annonce le lancement de notre nouvelle offre, ton direct, un seul appel à l'action." }, 1130 { ou: 'marketing', titre: 'Préparer une relance e-mail', quoi: 'Objet sous 50 caractères, une idée par paragraphe.', exemple: "Prépare-moi un e-mail de relance pour des prospects qui n'ont pas répondu depuis trois semaines." }, 1131 { ou: 'chat', titre: 'Trouver les angles', quoi: 'Explorer avant de rédiger, dans le chat.', exemple: "Donne-moi cinq angles différents pour parler de notre produit à des indépendants, et dis lequel tu défends." }, 1132 ], 1133 }, 1134 { id: 'ventes', label: 'Ventes', icone: 'briefcase', 1135 prompts: [ 1136 { ou: 'doc', titre: 'Relancer un prospect', quoi: 'Quelques relances écrites pour un prospect devenu silencieux.', exemple: "Rédige trois relances espacées pour un prospect qui ne répond plus depuis un mois, de plus en plus courtes." }, 1137 { ou: 'business', titre: 'Qualifier une opportunité', quoi: "Une grille, et une décision au bout â pas un avis.", exemple: "Analyse cette opportunité et décide de la prochaine étape avec une grille de qualification explicite." }, 1138 { ou: 'slides', titre: 'Monter un argumentaire', quoi: 'Où on gagne, où on est exposé, quoi dire.', exemple: "Monte-moi un argumentaire de vente en dix diapos face à un concurrent moins cher." }, 1139 ], 1140 }, 1141 { id: 'produit', label: 'Produit', icone: 'map', 1142 prompts: [ 1143 { ou: 'slides', titre: 'Présenter une feuille de route', quoi: 'Une idée par diapo, une demande chiffrée à la fin.', exemple: "Prépare une présentation de feuille de route produit sur deux trimestres, dix diapos." }, 1144 { ou: 'business', titre: 'Cadrer une décision', quoi: 'Les options, ce quâelles coûtent, celle quâon défend.', exemple: "Cadre-moi la décision « construire ou acheter » pour notre brique de facturation." }, 1145 { ou: 'chat', titre: 'Faire parler les retours', quoi: 'Ce que les retours disent vraiment, sans les lisser.', exemple: "Voici des retours utilisateurs : quels s
1145ont les trois problèmes de fond, et lequel est le plus coûteux à ignorer ?" }, 1146 ], 1147 }, 1148 { id: 'ingenieur', label: 'Ingénieur', icone: 'code', 1149 prompts: [ 1150 { ou: 'code', titre: 'Ãcrire un test qui casse', quoi: "Un test qu'on n'a pas vu échouer ne prouve rien.", exemple: "Ãcris-moi un test qui échoue sur ce défaut AVANT le correctif, puis corrige." }, 1151 { ou: 'code', titre: 'Regarder pourquoi câest lent', quoi: 'On mesure avant dâoptimiser.', exemple: "Cette page est lente : mesure d'abord, dis-moi où part le temps, et ne propose une optimisation qu'après." }, 1152 { ou: 'labs', titre: 'Prototyper une idée', quoi: 'Une simulation, un tracé, un mini-jeu quâon regarde tourner.', exemple: "Fais-moi une simulation interactive de propagation d'une file d'attente, et montre-la moi." }, 1153 ], 1154 }, 1155 { id: 'conception', label: 'Conception', icone: 'palette', 1156 prompts: [ 1157 { ou: 'design', titre: 'Créer un logo', quoi: 'Il se juge à 16 pixels et en noir et blanc.', exemple: "Crée-moi un logo simple pour une brasserie artisanale, lisible en tout petit." }, 1158 { ou: 'design', titre: 'Décliner une identité', quoi: 'Couleurs, typographies, usages.', exemple: "Décline une identité visuelle complète à partir de ce logo : couleurs, typographies, exemples d'usage." }, 1159 { ou: 'web', titre: 'Maquetter une page', quoi: 'Une page qui tient dans un seul fichier.', exemple: "Maquette une landing page avec un seul objectif et un seul bouton, mobile d'abord." }, 1160 ], 1161 }, 1162 { id: 'finance', label: 'Finance', icone: 'wallet', 1163 prompts: [ 1164 { ou: 'business', titre: 'Bâtir un modèle', quoi: 'Hypothèses nommées, chiffres qui se recalculent.', exemple: "Construis un modèle financier sur trois ans pour un service par abonnement, hypothèses écrites en clair." }, 1165 { ou: 'data', titre: 'Lire des chiffres', quoi: 'Le code tourne vraiment, avec pandas.', exemple: "Analyse ce tableau de dépenses et dis-moi ce qui a bougé, avec un graphique." }, 1166 { ou: 'espaceCfo', titre: 'Mettre de lâordre chez soi', quoi: 'Budget, méthode, sans chiffres inventés.', exemple: "Comment établir un budget mensuel qui tienne, avec la règle 50/30/20 ?" }, 1167 ], 1168 }, 1169 { id: 'juridique', label: 'Juridique', icone: 'law', 1170 prompts: [ 1171 { ou: 'doc', titre: 'Un modèle de contrat', quoi: 'Obligations réciproques, clauses numérotées, variables entre crochets.', exemple: "Rédige un modèle de contrat de prestation, clauses numérotées, variables entre crochets." }, 1172 { ou: 'espaceJuridique', titre: 'Comprendre une règle', quoi: "Comprendre, pas trancher â ce n'est pas un conseil.", exemple: "Explique-moi le RGPD simplement, et dis-moi ce que je dois vérifier chez moi." }, 1173 { ou: 'chat', titre: 'Préparer une question dâavocat', quoi: 'Arriver avec le dossier fait.', exemple: "Aide-moi à préparer les questions précises à poser à un avocat sur ce litige." }, 1174 ], 1175 }, 1176 { id: 'operations', label: 'Opérations', icone: 'gear', 1177 prompts: [ 1178 { ou: 'coworker', titre: 'Prioriser sa semaine', quoi: 'Par conséquence si on ne fait rien.', exemple: "Je suis débordé : aide-moi à prioriser cette liste par conséquence si je ne fais rien." }, 1179 { ou: 'dispatch', titre: 'Découper une grosse mission', quoi: 'Un plan, puis des tâches qui tournent en parallèle.', exemple: "Découpe la préparation de notre salon professionnel en tâches, et dis qui fait quoi." }, 1180 { ou: 'automate', titre: 'Automatiser un geste répété', quoi: 'Une automatisation par surface, pas deux.', exemple: "Quel geste je répète chaque semaine pourrait être automatisé, et comment on le poserait ?" }, 1181 ], 1182 }, 1183 { id: 'rh', label: 'Ressources humaines', icone: 'people', 1184 prompts: [ 1185 { ou: 'doc', titre: 'Une fiche de poste', quoi: 'Ce que la personne fera, pas ce quâelle aura lu.', exemple: "Rédige une fiche de poste pour un chargé de projet : missions, résultats attendus, critères d'évaluation." }, 1186 { ou: 'doc', titre: 'Un livret dâaccueil', quoi: 'Ce quâon aurait aimé savoir le premier jour.', exemple: "Ãcris un livret d'accueil pour une nouvelle recrue, une page par semaine du premier mois." }, 1187 { ou: 'doc', titre: 'Un module de formation interne', quoi: 'Objectifs observables, évaluation qui les mesure.', exemple: "Construis un module de formation interne sur la sécurité au poste de travail, avec un quiz final." }, 1188 ], 1189 }, 1190 { id: 'recherche', label: 'Recherche', icone: 'loupe', 1191 prompts: [ 1192 { ou: 'espaceDecouvrir', titre: 'Suivre un sujet', quoi: "
1192Le Fil va lire, il ne récite pas de mémoire.", exemple: "Fais le point sur ce qui a bougé cette semaine sur mon sujet, avec les sources." }, 1193 { ou: 'espaceEducation', titre: 'Chercher des travaux', quoi: 'Articles, revues, sujets de fond.', exemple: "Trouve-moi des travaux récents sur l'apprentissage par la pratique, et résume-les." }, 1194 { ou: 'chat', titre: 'Recouper deux sources', quoi: "Ce qui n'est pas recoupé se dit comme tel.", exemple: "Voici deux articles qui se contredisent : dis-moi où ils divergent et ce qu'on peut affirmer." }, 1195 ], 1196 }, 1197 { id: 'donnees', label: 'Données', icone: 'chart', 1198 prompts: [ 1199 { ou: 'data', titre: 'Nettoyer un jeu de données', quoi: 'Ce quâon jette se compte et se dit.', exemple: "Nettoie ce jeu de données et dis-moi exactement ce que tu as retiré et pourquoi." }, 1200 { ou: 'data', titre: 'Tracer un graphique', quoi: 'Un graphique, pas un tableau de bord â on en demande un.', exemple: "Trace-moi un graphique de l'évolution mensuelle, et explique ce qu'il montre." }, 1201 { ou: 'data', titre: 'Monter un tableau de bord', quoi: 'Les indicateurs qui décident, pas tous les chiffres.', exemple: "Monte-moi un tableau de bord avec les quatre indicateurs qui comptent pour cette activité." }, 1202 ], 1203 }, 1204] 1205 1206/* ââ LES CAS D'USAGE â UNE TABLE à PART, ET C'EST VOULU âââââââââââââââââââ 1207 * 1208 * Ils étaient d'abord imbriqués dans chaque rôle. Ãa marchait pour la page 1209 * d'un rôle, et pour RIEN d'autre : « Toutes les ressources » doit pouvoir les 1210 * lister tous, les compter, les filtrer par rôle ET par lieu. Une donnée qu'on 1211 * ne peut lire que par une seule entrée finit toujours par être recopiée à la 1212 * seconde â c'est le défaut que cette Académie existe pour refermer. 1213 * 1214 * `kind` est la clé de `renderTemplateThumb` : l'ESQUISSE du genre produit, 1215 * celle des pages de studio. Elle dit ce que le cas FABRIQUE. Pas de photo : 1216 * aucune banque n'a d'image de « préparer un appel de vente », et une vignette 1217 * qui ne répond pas laisserait un trou gris. 1218 * `ou` est le lieu où le cas se pratique â la garde vérifie qu'il existe. */ 1219const ACADEMIE_CAS = [ 1220 { id: 'campagne-bout-en-bout', role: 'marketing', ou: 'marketing', minutes: 20, kind: 'email', titre: 'Une campagne de bout en bout', 1221 quoi: 'Du positionnement aux visuels, en passant par les textes.', 1222 etapes: ['Poser le positionnement et la cible dans Marketing.', 'Faire écrire les textes â post, e-mail, page.', 'Passer à Iris pour les visuels, avec le même positionnement.', 'Ranger le tout dans les fichiers du projet.'] }, 1223 { id: 'calendrier-editorial', role: 'marketing', ou: 'coworker', minutes: 12, kind: 'list', titre: 'Tenir un calendrier éditorial', 1224 quoi: 'Ce qui part quand, et ce qui reste à écrire.', 1225 etapes: ['Lister les sujets et les dates au Coworker.', 'Faire rédiger au fil de lâeau dans Marketing.', 'Poser les rappels une semaine avant chaque date.'] }, 1226 { id: 'compte-rendu-appel', role: 'ventes', ou: 'doc', minutes: 10, kind: 'doc', titre: "D'un compte rendu d'appel au suivi", 1227 quoi: 'Transformer des notes brutes en actions datées.', 1228 etapes: ['Coller le compte rendu dans le Studio Doc.', 'Faire extraire les décisions et les prochaines étapes.', 'Poser les échéances avec le Coworker.'] }, 1229 { id: 'argumentaire-concurrent', role: 'ventes', ou: 'slides', minutes: 15, kind: 'slides', titre: 'Se préparer face à un concurrent', 1230 quoi: 'Où on gagne, où on est exposé, quoi dire.', 1231 etapes: ['Rassembler ce quâon sait du concurrent dans le chat.', 'Monter lâargumentaire en dix diapos.', 'Garder une diapo pour les objections les plus fréquentes.'] }, 1232 { id: 'intuition-a-spec', role: 'produit', ou: 'doc', minutes: 18, kind: 'doc', titre: "D'une intuition à une spécification", 1233 quoi: 'Passer dâune idée à quelque chose quâon peut construire.', 1234 etapes: ['Explorer dans le chat, sans format imposé.', 'Ãcrire la spécification dans le Studio Doc.', 'En tirer une présentation pour la décision.'] }, 1235 { id: 'revue-de-retours', role: 'produit', ou: 'data', minutes: 15, kind: 'chart', titre: 'Faire parler une masse de retours', 1236 quoi: 'Ce que les retours disent vraiment, sans les lisser.', 1237 etapes: ['Charger les retours dans Insight.', 'Faire regrouper par problème, pas par mot-clé.', 'Trancher ce qui coûte le plus cher à ignorer.'] }, 1238 { id: 'correctif-qui-tient', role: 'ingenieur', ou: 'code', minutes: 20, kind: 'code', titre: 'Un correctif qui tient', 1239 quoi: 'Reproduire, prouver, corriger, revérifier.', 1240 etapes: ['Reproduire le défaut dans Codao.', 'Ãcrire le test qui échoue AVANT le correctif.', 'Corriger, puis relancer lâaperçu pour voir la page tourner.'] }, 1241 { id: 'lecture-de-depot', role: 'ingenieur', ou: 'code', minutes: 15, kind: 'list', titre: 'Comprendre un dépôt quâon ne connaît pas', 1242 quoi: 'Par où entrer quand tout est nouveau.', 1243 etapes: ['Faire lister les points dâentrée réels, pas lâarborescence.', 'Faire suivre un chemin de bout en bout.', 'Ãcrire ce quâon a compris, pour la prochaine fois.'] }, 1244 { id: 'marque-vers-site', role: 'conception', ou: 'web', minutes: 20, kind: 'web', titre: "D'une marque à un site", 1245 quoi: 'Garder la même identité dâun lieu à lâautre.', 1246 etapes: ['Poser lâidentité dans Iris.', 'La reprendre dans le Studio No Code pour la page.', 'Regarder la page tourner dans lâaperçu.'] }, 1247 { id: 'jeu-de-visuels', role: 'conception', ou: 'design', minutes: 12, kind: 'image', titre: 'Décliner un visuel en série', 1248 quoi: 'Le même message, aux bons formats.', 1249 etapes: ['Fixer le visuel de référence dans Iris.', 'Décliner aux formats voulus.', 'Ranger la série dans la médiathèque.'] }, 1250 { id: 'revue-tresorerie', role: 'finance', ou: 'data', minutes: 18, kind: 'chart', titre: 'Une revue de trésorerie', 1251 quoi: 'Des chiffres bruts à une décision.', 1252 etapes: ['Charger les données dans Insight.', 'Faire calculer, pas raconter.', 'Porter la conclusion en première ligne dans le Studio Doc.'] }, 1253 { id: 'modele-trois-ans', role: 'finance', ou: 'business', minutes: 25, kind: 'chart', titre: 'Un modèle à trois ans', 1254 quoi: 'Des hypothèses quâon peut discuter une par une.', 1255 etapes: ['Nommer les hypothèses dans Business.', 'Faire recalculer à chaque changement.', 'Sortir la version investisseurs en diapos.'] }, 1256 { id: 'relire-un-contrat', role: 'juridique', ou: 'doc', minutes: 15, kind: 'letter', titre: 'Relire un contrat reçu', 1257 quoi: 'Repérer ce qui engage, avant de signer.', 1258 etapes: ['Joindre le contrat dans le chat.', 'Faire ressortir les obligations et les échéances.', 'Ãcrire la liste des questions dans le Studio Doc.'] }, 1259 { id: 'procedure-ecrite', role: 'operations', ou: 'coworker', minutes: 12, kind: 'list', titre: 'Une procédure écrite une fois', 1260 quoi: 'Ce qui se répète se documente.', 1261 etapes: ['Décrire le geste au Coworker.', 'Faire écrire la procédure dans le Studio Doc.', 'Poser le rappel récurrent.'] }, 1262 { id: 'mission-decoupee', role: 'operations', ou: 'dispatch', minutes: 15, kind: 'list', titre: 'Une grosse mission découpée', 1263 quoi: 'Un plan, puis des tâches qui tournent en parallèle.', 1264 etapes: ['Décrire la mission au Dispatch.', 'Relire le découpage avant de lancer.', 'Suivre les tâches de fond pendant quâon fait autre chose.'] }, 1265 { id: 'arrivee-recrue', role: 'rh', ou: 'doc', minutes: 20, kind: 'doc', titre: "L'arrivée d'une nouvelle personne", 1266 quoi: 'Du recrutement au premier mois.', 1267 etapes: ['Ãcrire la fiche de poste dans le Studio Doc.', 'Préparer le livret dâaccueil.', 'Monter la formation dans le Studio Ãducation.'] }, 1268 { id: 'note-de-synthese', role: 'recherche', ou: 'doc', minutes: 18, kind: 'doc', titre: 'Une note de synthèse', 1269 quoi: 'La conclusion se lit en premier.', 1270 etapes: ['Rassembler les sources depuis Le Fil.', 'Faire recouper dans le chat.', 'Ãcrire la note dans le Studio Doc, conclusion en tête.'] }, 1271 { id: 'veille-hebdo', role: 'recherche', ou: 'espaceDecouvrir', minutes: 10, kind: 'list', titre: 'Une veille qui tient dans la semaine', 1272 quoi: 'Ce qui a bougé, et ce qui nâest pas confirmé.', 1273 etapes: ['Poser les sujets suivis dans Le Fil.', 'Faire distinguer le confirmé du rapporté.', 'Garder la note de la semaine dans les fichiers du projet.'] }, 1274 { id: 'export-vers-decision', role: 'donnees', ou: 'data', minutes: 20, kind: 'chart', titre: "D'un export brut à une décision", 1275 quoi: 'Charger, nettoyer, calculer, conclure.', 1276 etapes: ['Charger lâexport dans Insight.', 'Nettoyer en comptant ce qui est jeté.', 'Calculer, tracer, puis écrire la conclusion.'] }, 1277 { id: 'tableau-de-bord', role: 'donnees', ou: 'data', minutes: 15, kind: 'chart', titre: 'Un tableau de bord qui sert', 1278 quoi: 'Les indicateurs qui décident, pas tous les chiffres.', 1279 etapes: ['Choisir quatre indicateurs, pas quinze.', 'Faire monter le tableau de bord.', 'Le relire dans un mois : ce qui nâa jamais servi sâenlève.'] }, 1280] 1281 1282/* ââ PLANIFIER UN BILAN D'APPRENTISSAGE âââââââââââââââââââââââââââââââââââ 1283 * Le bouton n'invente pas une machine : il ouvre le chat avec la demande
1284 * ÃCRITE EN BROUILLON, et c'est CREA qui pose le rappel récurrent avec les 1285 * outils qu'elle a. Un bouton qui promettrait une planification que rien 1286 * n'exécute serait pire qu'une absence de bouton. */ 1287const ACADEMIE_BILAN = "Je veux faire un bilan d'apprentissage régulier avec toi. Demande-moi ce que je cherche à améliorer et à quelle fréquence je veux faire le point, propose un jour et une heure, et attends ma confirmation avant de créer quoi que ce soit. Une fois que j'aurai confirmé, pose un seul rappel récurrent. à chaque fois, demande-moi ce que j'ai essayé depuis la dernière fois, entraîne-moi sur une nouveauté, et suggère quoi essayer ensuite." 1288 1289/* `ACADEMIE_MODES_SEANCE` EST PARTIE AVEC LA SÃANCE QU'ELLE PROPOSAIT. 1290 Quatre modes â quiz adaptatif, examen blanc, cours, cartes mémoire â que 1291 l'Académie offrait et que le Studio Ãducation tenait. Le studio est 1292 supprimé ; plus personne n'appelait `window.academieModeSeance` depuis que 1293 l'Ãcole a ses cinq écrans, et la table ne servait plus qu'à valider un 1294 identifiant que rien n'écrivait. */ 1295 1296/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1297 * LES CINQ VUES DE L'ACADÃMIE, ET CE QUI LES RELIE 1298 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1299 * 1300 * Aperçu cours en vedette · prise en main · rôles · bilan 1301 * Rôle essayer un prompt · cas d'usage 1302 * Lieu commencer ici · essayer une tâche · tout 1303 * Ressources onglets · recherche · filtre par lieu · compteur 1304 * Ãducation les modes de séance · les matières 1305 * 1306 * ââ IL N'Y A QU'UN CATALOGUE âââââââââââââââââââââââââââââââââââââââââââââ 1307 * 1308 * Les quatre premières vues ne portent AUCUN contenu à elles : elles filtrent 1309 * `_academieCatalogue()`, qui met les cours, les tutoriels et les cas d'usage 1310 * sous une forme commune. Un « Cours » vu depuis un rôle, depuis un lieu ou 1311 * depuis « Toutes les ressources » est le MÃME objet â sinon le compteur d'un 1312 * écran finirait par contredire la liste d'un autre, et c'est le genre de 1313 * désaccord qu'on ne voit jamais parce qu'on ne regarde pas les deux en même 1314 * temps. 1315 * 1316 * ââ LA LISTE DES LIEUX EST DÃRIVÃE, PAS ÃCRITE âââââââââââââââââââââââââââ 1317 * 1318 * `_academieLieux()` relève les lieux RÃELLEMENT cités par les ressources. Une 1319 * table écrite à la main aurait un jour un lieu sans ressource â une entrée de 1320 * filtre qui ne rend rien â ou une ressource dans un lieu absent du filtre, 1321 * donc introuvable. Les deux sont invisibles à la relecture. 1322 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1323 1324/* LA PORTE VERS UN LIEU. `openModeTab` d'un mode inconnu sort à sa première 1325 ligne, en silence : on ne l'appelle donc que sur ce que les tables 1326 déclarent, et la garde vérifie que ce qu'elles déclarent existe. */ 1327window.academieOuvrir = function (cible) { 1328 if (!cible) return 1329 if (String(cible).startsWith('espace')) { openSpace(cible); return } 1330 /* LA LISTE DES STUDIOS N'EST PAS UN MODE â c'est une barre. `openModeTab` 1331 d'un mode inconnu sort à sa première ligne : le tutoriel « Un studio est 1332 un LIEU » aurait porté un bouton MUET, ce que rien ne signale. 1333 On OUVRE, on ne bascule pas : `montrerLaBarre` refermerait la barre déjà 1334 posée, et un bouton nommé « Voir les studios » qui les cache une fois sur 1335 deux est pire qu'un bouton mort â on croit avoir raté son clic. */ 1336 /* L'ÃCOLE N'EST PAS UN MODE NON PLUS â c'est une SECTION de cet espace-ci. 1337 Elle était atteinte par « Ouvrir le Studio Ãducation », qui menait à un
1338 studio maintenant supprimé : le clic aurait été MUET, ce que rien ne 1339 signale. On y va donc par la porte de la section. */ 1340 if (cible === 'ecole') { 1341 window.academieSection('education') 1342 return 1343 } 1344 if (cible === 'studios') { 1345 closeAllNavSheets() 1346 STATE.studiosSheetOpen = true 1347 STATE.barreMonde = 'studios' 1348 STATE.barreFermeeParUsager = false 1349 save(); render() 1350 return 1351 } 1352 openModeTab(cible) 1353} 1354 1355/* LA PORTE VERS UNE MATIÃRE EST PARTIE AVEC CE QU'ELLE OUVRAIT. 1356 `window.academieOuvrirMatiere` posait une matière d'`EDU_DOMAINS` et ouvrait 1357 le Studio Ãducation â c'était le clic qui, depuis « Code de la route », 1358 atterrissait sur une barre de chat. L'Ãcole a ses cinq écrans, et le studio 1359 n'a plus de catalogue : plus rien n'appelait cette porte. 1360 La laisser aurait laissé un chemin vivant vers l'écran qu'on vient de 1361 retirer, et une garde qui prétend le protéger. */ 1362 1363// Une seule fonction pour aller quelque part dans l'Académie : quatre 1364// gestionnaires qui posent chacun leur bout d'état, c'est la garantie qu'un 1365// jour l'un oubliera de nettoyer ce que l'autre avait posé â et on se 1366// retrouverait dans « Toutes les ressources » avec un rôle encore ouvert. 1367window.academieVue = function (vue, cle) { 1368 STATE.academieVue = ['apercu', 'role', 'lieu', 'ressources', 'fiche', 'registre', 'reglages'].includes(vue) ? vue : 'apercu' 1369 /* LE REGISTRE EST UNE VUE D'EXPLOITATION. Le bouton qui y mène ne s'affiche 1370 pas au client â mais un état enregistré, un aperçu client pris depuis un 1371 compte propriétaire, ou un simple `academieVue('registre')` tapé à la main 1372 y entreraient quand même. On refuse ici, à la porte. */ 1373 if (STATE.academieVue === 'registre' && isClientView()) STATE.academieVue = 'apercu' 1374 /* LA VUE DU REGISTRE DEMANDE LES DEUX, ET NE COMPTE SUR PERSONNE. Elle 1375 lisait les ressources et attendait que les SOURCES soient chargées par le 1376 filet du rendu â qui existe, mais ailleurs. Mesuré à l'écran : la vue 1377 restait sur « Lecture du registre⦠», et un chargement qui ne finit jamais 1378 se lit comme une panne. Une vue qui a besoin de quelque chose le demande. */ 1379 if (STATE.academieVue === 'registre') { 1380 STATE.academieSourceOuverte = null 1381 window.academieChargerSources() 1382 window.academieChargerRessources(null) 1383 } 1384 STATE.academieCle = cle || null 1385 STATE.academieCherche = '' 1386 save(); render() 1387} 1388window.academieSection = function (id) { 1389 STATE.academieSection = ACADEMIE_SECTIONS.some(s => s.id === id) ? id : 'tutoriels' 1390 window.academieVue('apercu') 1391} 1392window.academieOnglet = function (id) { 1393 STATE.academieOnglet = _academieOngletsDuCatalogue().some(o => o.id === id) ? id : 'tout' 1394 save(); render() 1395} 1396window.academieFiltreLieu = function (id) { 1397 STATE.academieLieu = id || null 1398 save(); render() 1399} 1400 1401/* LA RECHERCHE NE PASSE PAS PAR `render()`, ET C'EST DÃLIBÃRÃ. Un rendu 1402 complet à chaque frappe reconstruit le champ, donc lui reprend le curseur â 1403 on tape « ven », on récupère « v ». On filtre donc le DOM déjà posé, et on 1404 met le compteur à jour au même endroit que lui : deux affichages du même 1405 nombre finiraient par diverger. */ 1406window.academieChercher = function (texte) { 1407 const q = String(texte || '').trim().toLowerCase() 1408 let vus = 0 1409 document.querySelectorAll('[data-academie-cherche]').forEach(el => { 1410 const ok = !q || el.getAttribute('data-academie-cherche').includes(q) 1411 el.style.display = ok ? '' : 'none' 1412 if (ok) vus++ 1413 }) 1414 const compteur = document.getElementById('academie-compteur') 1415 if (compteur) compteur.textContent = _academieCompte(vus) 1416 const vide = document.getElementById('academie-vide') 1417 if (vide) vide.style.display = vus ? 'none' : '' 1418} 1419 1420/* ââ LE CATALOGUE â UNE SEULE FORME POUR TROIS FAMILLES âââââââââââââââââââ */ 1421/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1422 * CE QUI EST IMPORTà EST UNE RESSOURCE DE L'ACADÃMIE â PAS UNE LIGNE DE REGISTRE 1423 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1424 * 1425 * Le connecteur Open edX marchait, la route d'import marchait, le registre 1426 * gardait. Et un cours importé ne s'affichait NULLE PART dans l'Académie : 1427 * `_academieCatalogue()` ne connaissait que les trois tables écrites ici. Le 1428 * seul écran qui montrait un cours importé était celui du registre â la 1429 * réserve. On remplissait une réserve sans porte sur la boutique. 1430 * 1431 * Mesuré : catalogue = 26 entrées, types « cours, tutoriels, cas ». Zéro 1432 * importée, quelle que soit la source branchée. 1433 * 1434 * Le catalogue reste UNE fonction et UNE forme : une ressource importée entre 1435 * par la même porte que les autres, avec la même clé `type:id`, et se retrouve 1436 * donc du même coup dans la recherche, dans le compteur, dans le filtre par 1437 * lieu, sur la page du lieu et dans sa propre fiche. C'est tout l'intérêt de 1438 * n'avoir qu'un catalogue. 1439 */ 1440const ACADEMIE_TYPES_IMPORTES = { 1441 'cours': { onglet: 'cours', label: 'Cours', icone: 'gradcap' }, 1442 'tutoriel': { onglet: 'tutoriels', label: 'Tutoriel', icone: 'bulb' }, 1443 'cas': { onglet: 'cas', label: "Cas d'usage", icone: 'bolt' }, 1444 'exercice': { onglet: 'exercices', label: 'Exercice', icone: 'science' }, 1445 'article': { onglet: 'documents', label: 'Article', icone: 'doc' }, 1446 'video': { onglet: 'documents', label: 'Vidéo', icone: 'play' }, 1447 'jeu-de-donnees': { onglet: 'documents', label: 'Jeu de données', icone: 'chart' }, 1448} 1449 1450/* LE CATALOGUE IMPORTà SE DEMANDE SANS FILTRE, ET IL A SON PROPRE CHARGEUR. 1451 `academieChargerRessources` sert l'écran d'administration : il filtre sur la 1452 source ouverte et remet la liste à `undefined` le temps de l'appel. Le
1453 catalogue s'en servirait mal â ouvrir une source dans l'administration 1454 viderait la boutique. Deux questions différentes, deux lectures. */ 1455window.academieChargerImportees = async function (force) { 1456 if (STATE.academieImportees !== undefined && !force) return 1457 STATE.academieImportees = null 1458 try { 1459 const r = await fetch('/api/academie/ressources', { headers: internalAuth() }) 1460 if (!r.ok) throw new Error('refus ' + r.status) 1461 const d = await r.json() 1462 STATE.academieImportees = Array.isArray(d.ressources) ? d.ressources : [] 1463 STATE.academieCredits = d.credits || {} 1464 } catch (e) { 1465 /* NULL, PAS []. Une panne de lecture rendrait sinon un catalogue amputé 1466 sans que rien ne le dise â et on croirait l'import perdu. */ 1467 STATE.academieImportees = null 1468 } 1469 render() 1470} 1471 1472function _academieImportees() { 1473 const liste = STATE.academieImportees 1474 if (!Array.isArray(liste)) return [] 1475 return liste.map(r => { 1476 const t = ACADEMIE_TYPES_IMPORTES[r.type] || ACADEMIE_TYPES_IMPORTES.article 1477 return { 1478 type: t.onglet, typeLabel: t.label, id: r.id, titre: r.titre, quoi: r.quoi || '', 1479 /* UNE RESSOURCE IMPORTÃE S'ÃTUDIE â son lieu est l'Ãcole. C'était le 1480 Studio Ãducation, qui n'existe plus : le filtre par lieu aurait gardé 1481 son entrée et le bouton n'aurait rien ouvert. Sans lieu du tout, elle 1482 sortirait du filtre ET de la page de lieu â présente dans le 1483 catalogue, introuvable par les deux chemins qui y mènent. */ 1484 ou: 'ecole', minutes: r.minutes || 0, kind: 'list', icone: t.icone, role: null, 1485 meta: [r.langue ? String(r.langue).toUpperCase() : '', r.minutes ? r.minutes + ' min' : '', r.auteur || ''].filter(Boolean).join(' · ') || 'importé', 1486 sourceId: r.sourceId, licence: r.licence, droits: r.droits || {}, 1487 url: r.url || '', auteur: r.auteur || '', langue: r.langue || '', 1488 } 1489 }) 1490} 1491 1492function _academieCatalogue() { 1493 const tutos = TUTO_PARCOURS.flatMap(p => (p.tutos || []).map(t => ({ 1494 type: 'tutoriels', typeLabel: 'Tutoriel', id: p.id + '/' + t.id, titre: t.titre, quoi: t.o, ou: t.ouvre, 1495 minutes: t.m, kind: 'doc', c1: p.c1, c2: p.c2, icone: null, 1496 meta: (t.etapes || []).length + ' étapes · ' + t.m + ' min', role: null, 1497 }))) 1498 const cas = ACADEMIE_CAS.map(c => ({ 1499 type: 'cas', typeLabel: "Cas d'usage", id: c.id, titre: c.titre, quoi: c.quoi, ou: c.ou, 1500 minutes: c.minutes, kind: c.kind, icone: null, 1501 meta: (ACADEMIE_ROLES.find(r => r.id === c.role) || {}).label + ' · ' + c.minutes + ' min', role: c.role, 1502 })) 1503 return tutos.concat(cas, _academieImportees()) 1504} 1505// Les lieux RELEVÃS dans le catalogue â jamais une liste écrite à côté. 1506function _academieLieux() { 1507 const vus = [] 1508 for (const r of _academieCatalogue()) if (!vus.includes(r.ou)) vus.push(r.ou) 1509 return vus 1510} 1511function _academieCompte(n) { 1512 const o = STATE.academieOnglet || 'tout' 1513 const mot = o === 'cours' ? 'cours' 1514 : o === 'tutoriels' ? (n > 1 ? 'tutoriels' : 'tutoriel') 1515 : o === 'cas' ? "cas d'usage" 1516 : o === 'exercices' ? (n > 1 ? 'exercices' : 'exercice') 1517 : o === 'documents' ? (n > 1 ? 'documents' : 'document') 1518 : o === 'webinaires' ? (n > 1 ? 'webinaires' : 'webinaire') 1519 : (n > 1 ? 'ressources' : 'ressource') 1520 return n + ' ' + mot 1521} 1522 1523// Le nom lisible d'un lieu, pris dans la table des modes plutôt que réécrit : 1524// un studio renommé demain se renomme ici aussi. Les espaces n'y sont pas â 1525// ils ont leur propre table â d'où le repli explicite. 1526const ACADEMIE_LIEUX_ESPACES = { chat: 'le chat', espaceDecouvrir: 'Le Fil', espaceCfo: 'CFO Personnel', espaceJuridique: 'Juridique', espaceEducation: 'la recherche académique', ecole: 'lâÃcole' } 1527function _academieNomDuLieu(id) { 1528 if (ACADEMIE_LIEUX_ESPACES[id]) return ACADEMIE_LIEUX_ESPACES[id] 1529 const m = (STATE.modes || []).find(x => x.id === id) 1530 return m ? 'le Studio ' + m.name : id 1531} 1532function _academieTitreDuLieu(id) { 1533 const n = _academieNomDuLieu(id) 1534 return n.charAt(0) === 'l' ? n.charAt(0).toUpperCase() + n.slice(1) : n 1535} 1536 1537/* ââ LES BRIQUES D'AFFICHAGE âââââââââââââââââââââââââââââââââââââââââââââââ */ 1538function _academieEnTete(fil, sousTitre, sousLeNom, aDroite) { 1539 /* DEUX EN-TÃTES, ET C'EST CE QUE MONTRENT LES CAPTURES. Une page de RÃLE 1540 reste dans l'Académie et le dit â « Créa Académie / Ventes ». Une page de 1541 catalogue, elle, PORTE son nom : « Toutes les ressources », avec le retour 1542 au-dessus. Mettre le fil partout donnait un titre sur deux lignes où le 1543 nom de la page passait après celui du lieu. */ 1544 const retour = fil ? `<button onclick="window.academieVue('apercu')" class="inline-flex items-center gap-1.5 text-[max(0.75rem,12px)] mb-3" style="color:var(--t-faint)">${_icon('chevronLeft', 'w-3.5 h-3.5')}Académie</button>` : '' 1545 const titre = (fil && !sousLeNom) 1546 ? `<h1 class="text-[max(2.125rem,34px)] font-bold" style="color:var(--t-primary)">${escHtml(fil)}</h1>` 1547 : `<div class="flex items-baseline gap-2 flex-wrap"> 1548 ${_spaceTitre('Créa Académie', 'text-[max(2.125rem,34px)] font-bold', 'color:var(--t-primary)', { chevron: 'w-5 h-5' })} 1549 ${fil ? `<span class="text-[max(2.125rem,34px)] font-bold" style="color:var(--t-faint)">/ ${escHtml(fil)}</span>` : ''} 1550 </div>` 1551 /* CE QUI SE POSE à DROITE, SUR LA LIGNE DU TITRE. Les deux sections étaient 1552 empilées SOUS le sous-titre, collées au bloc du titre â signalé : elles se
1553 mêlaient à lui, et la place à droite restait vide. Une navigation n'est pas 1554 une suite du titre : elle se tient en face, sur la même ligne. 1555 `items-start` et pas `items-center` : le bloc de gauche fait deux lignes, 1556 et centrer ferait descendre l'interrupteur au milieu du sous-titre. 1557 Il repasse SOUS le titre quand la place manque (`flex-wrap`), au lieu 1558 d'écraser le titre sur un écran étroit. */ 1559 return `<div class="mb-6 flex items-start justify-between gap-5 flex-wrap"> 1560 <div class="min-w-0 flex-1" style="min-width:min(100%,260px)">${retour}${titre} 1561 <p class="text-[max(0.875rem,14px)] mt-1.5" style="color:var(--t-dim)">${escHtml(sousTitre)}</p> 1562 </div> 1563 ${aDroite || ''} 1564 </div>` 1565} 1566 1567function _academieRangee(titre, sousTitre, contenu, lien) { 1568 return `<section class="mb-9"> 1569 <div class="flex items-end justify-between gap-3 mb-3"> 1570 <div class="min-w-0"> 1571 <h2 class="text-[max(1.125rem,18px)] font-bold" style="color:var(--t-primary)">${escHtml(titre)}</h2> 1572 ${sousTitre ? `<p class="text-[max(0.8125rem,13px)] mt-0.5" style="color:var(--t-dim)">${escHtml(sousTitre)}</p>` : ''} 1573 </div> 1574 ${lien || ''} 1575 </div> 1576 ${contenu} 1577 </section>` 1578} 1579function _academieLienTout(vue, cle) { 1580 return `<button onclick="window.academieVue('${vue}'${cle ? `,'${cle}'` : ''})" class="shrink-0 inline-flex items-center gap-1.5 text-[max(0.8125rem,13px)] font-semibold" style="color:var(--t-primary)">Tout afficher ${_icon('chevronRight', 'w-3.5 h-3.5')}</button>` 1581} 1582 1583// La vignette : l'aplat pastel des pages de studio (`tpl-swatch-N`) avec, par 1584// dessus, l'ESQUISSE du genre produit (`renderTemplateThumb`) ou le trait du 1585// sujet. Pas de photo : aucune banque n'a d'image de « la boucle d'agent », et 1586// une vignette qui ne répond pas laisserait un trou gris à la place. 1587function _academieVignette(r, hauteur, badge) { 1588 /* `tpl-swatch-N` ne pose que les DEUX VARIABLES `--tpl-bg` et `--tpl-fg` : 1589 c'est `.tpl-thumb` qui les emploie. Poser la classe de teinte sans elle 1590 donne une tuile grise â mesuré à la première capture, et c'est le genre de 1591 détail qui ne se voit qu'à l'écran. On prend donc les deux. */ 1592 const teinte = 'tpl-swatch-' + ([...String(r.id)].reduce((a, c) => a + c.charCodeAt(0), 0) % 8) 1593 const dedans = r.icone 1594 ? `<span style="color:var(--tpl-fg,var(--accent));opacity:.85">${_icon(r.icone, 'w-7 h-7')}</span>` 1595 : renderTemplateThumb(r.kind) 1596 return `<span class="tpl-thumb ${teinte} relative" style="${hauteur};flex:none;border-radius:0;${r.icone ? 'align-items:center' : ''}"> 1597 ${dedans} 1598 ${badge ? `<span class="absolute top-2 right-2 rounded-md px-1.5 py-0.5 text-[max(0.625rem,10px)] font-semibold" style="background:var(--bg-primary);color:var(--t-secondary)">${escHtml(badge)}</span>` : ''} 1599 </span>` 1600} 1601 1602// Une ligne du catalogue : vignette à gauche, titre, description, méta â la 1603// forme de « Toutes les ressources ». 1604function _academieLigne(r) { 1605 const cherche = escHtml((r.titre + ' ' + r.quoi + ' ' + r.typeLabel + ' ' + _academieNomDuLieu(r.ou)).toLowerCase()) 1606 return `<button data-academie-cherche="${cherche}" onclick="window.academieVue('fiche','${r.type}:${r.id}')" class="w-full text-left flex items-stretch gap-0 rounded-2xl overflow-hidden transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 1607 ${_academieVignette(r, 'width:120px;min-height:92px')} 1608 <span class="flex-1 min-w-0 p-3.5"> 1609 <span class="block text-[max(0.9375rem,15px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(r.titre)}</span> 1610 <span class="block text-[max(0.8125rem,13px)] mt-1 leading-snug" style="color:var(--t-dim)">${escHtml(r.quoi)}</span> 1611 <span class="block text-[max(0.75rem,12px)] mt-1.5" style="color:var(--t-faint)">${escHtml(r.typeLabel)} · ${escHtml(r.meta)}</span> 1612 </span> 1613 </button>` 1614} 1615 1616// Une carte du catalogue : vignette en haut, la forme de « Commencer ici ». 1617function _academieCarte(r) { 1618 const cherche = escHtml((r.titre + ' ' + r.quoi + ' ' + r.typeLabel).toLowerCase()) 1619 return `<button data-academie-cherche="${cherche}" onclick="window.academieVue('fiche','${r.type}:${r.id}')" class="text-left rounded-2xl overflow-hidden transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 1620 ${_academieVignette(r, 'width:100%;height:112px', r.type === 'cours' ? '' : r.minutes + ' min')} 1621 <span class="block p-3.5">
1622 <span class="block text-[max(0.9375rem,15px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(r.titre)}</span> 1623 <span class="block text-[max(0.8125rem,13px)] mt-1 leading-snug" style="color:var(--t-dim)">${escHtml(r.quoi)}</span> 1624 <span class="block text-[max(0.75rem,12px)] mt-2" style="color:var(--t-faint)">${escHtml(r.typeLabel)} · ${escHtml(r.meta)}</span> 1625 </span> 1626 </button>` 1627} 1628 1629/* UNE CARTE DE PROMPT â le geste qui existe déjà . `data-studio-template-card` 1630 ouvre le lieu et écrit le texte dans sa barre SANS l'envoyer, comme le dit 1631 l'écran : « chaque prompt s'ouvre en brouillon ». */ 1632function _academieCartePrompt(p) { 1633 return `<button data-studio-template-card="${escHtml(p.ou)}" data-template-exemple="${escHtml(p.exemple)}" class="text-left rounded-2xl p-3.5 transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)" title="${escHtml(p.exemple)}"> 1634 <span class="block text-[max(0.6875rem,11px)] mb-1" style="color:var(--t-faint)">Essayer dans ${escHtml(_academieNomDuLieu(p.ou))}</span> 1635 <span class="block text-[max(0.875rem,14px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(p.titre)}</span> 1636 <span class="block text-[max(0.75rem,12px)] mt-1 leading-snug" style="color:var(--t-dim)">${escHtml(p.quoi)}</span> 1637 </button>` 1638} 1639 1640 1641/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1642 * LES WEBINAIRES â LA STRUCTURE EXISTE AVANT LE CONTENU, ET C'EST VOULU 1643 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1644 * 1645 * « Il n'en a pas, mais il va en avoir. Il faut tout créer. » 1646 * 1647 * Cette table est VIDE aujourd'hui, et l'écran le dit en toutes lettres au 1648 * lieu d'afficher une rangée fantôme. La différence entre les deux est tout ce 1649 * qui compte : un onglet qui annonce « aucune séance programmée â proposez-en 1650 * une » est une invitation ; le même onglet avec trois cartes inventées est un 1651 * mensonge qu'on découvre en cliquant. 1652 * 1653 * La forme d'une séance, pour celle qu'on posera demain : 1654 * { id, titre, quoi, quand (ISO), duree, etat: 'a-venir' | 'rediffusion', 1655 * par: ['qui anime'], lien } 1656 * `quand` est une date ISO, jamais un texte : « le 12 » ne se trie pas et ne 1657 * se compare pas à maintenant, donc une séance passée resterait « à venir » 1658 * pour toujours. 1659 */ 1660/* LA TABLE EST PARTIE AU SERVEUR. Ãcrite ici, elle était vide POUR TOUJOURS : 1661 aucune route, aucun stockage, aucun moyen d'en créer une. « Aucune séance 1662 programmée » n'était pas un état, c'était une propriété du produit. */ 1663window.academieChargerSeances = async function (force) { 1664 if (STATE.academieSeances !== undefined && !force) return 1665 STATE.academieSeances = null 1666 try { 1667 const r = await fetch('/api/academie/seances', { headers: internalAuth() }) 1668 if (!r.ok) throw new Error('refus ' + r.status) 1669 const d = await r.json() 1670 STATE.academieSeances = Array.isArray(d.seances) ? d.seances : [] 1671 STATE._academieSeancesErreur = '' 1672 } catch (e) { 1673 /* NULL, PAS []. « Je n'ai pas pu regarder » et « il n'y en a pas » sont 1674 deux phrases différentes, et l'une invite quand l'autre inquiète. */ 1675 STATE.academieSeances = null 1676 STATE._academieSeancesErreur = e.message === 'refus 401' ? 'connectez-vous pour voir les séances' : e.message 1677 } 1678 render() 1679} 1680 1681window.academieProposerSeance = async function () { 1682 const lu = (id) => (document.getElementById(id) || {}).value || '' 1683 const d = await _academiePoster('/api/academie/seances', { 1684 titre: lu('seance-titre'), quoi: lu('seance-quoi'), 1685 quand: lu('seance-quand'), minutes: Number(lu('seance-minutes')), 1686 matiere: lu('seance-matiere'), lien: lu('seance-lien'), 1687 par: lu('seance-par') ? [lu('seance-par')] : [], 1688 }) 1689 if (d) { STATE.academieSeanceFormOuvert = false; window.academieChargerSeances(true) } 1690 else render() 1691} 1692 1693window.academieInscription = async function (id) { 1694 const d = await _academiePoster('/api/academie/seances/' + encodeURIComponent(id) + '/inscription', {}) 1695 if (d) window.academieChargerSeances(true) 1696 else render() 1697} 1698 1699window.academieAnnulerSeance = async function (id) { 1700 try { 1701 const r = await fetch('/api/academie/seances/' + encodeURIComponent(id), { method: 'DELETE', headers: internalAuth() }) 1702 const d = await r.json().catch(() => ({})) 1703 if (!r.ok || d.ok === false) { STATE._academieRefus = d.detail ? `${d.raison || 'refus'} â ${d.detail}` : `le serveur a répondu ${r.status}`; render(); return } 1704 STATE._academieRefus = '' 1705 window.academieChargerSeances(true) 1706 } catch (e) { STATE._academieRefus = e.message; render() } 1707} 1708 1709window.academieSeanceForm = function (ouvert) { STATE.academieSeanceFormOuvert = !!ouvert; render() } 1710 1711const ACADEMIE_ETAT_SEANCE = { 1712 'en-cours': { label: 'En direct', couleur: 'var(--danger)' }, 1713 'a-venir': { label: 'à venir', couleur: 'var(--accent)' }, 1714 rediffusion: { label: 'Rediffusion', couleur: 'var(--t-faint)' }, 1715} 1716 1717/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1718 * LE REGISTRE DES SOURCES â IL VIT AU SERVEUR, ON LE LIT 1719 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1720 *
1721 * Il a été une table ÃCRITE ICI, et c'était un registre qui n'en était pas un : 1722 * dans le navigateur de chacun, donc nulle part. Une source branchée par 1723 * quelqu'un n'existait pas pour le suivant, et rien ne pouvait vérifier ce 1724 * qu'elle prétendait â le client se croyait sur parole. 1725 * 1726 * Tout ce qui décide est maintenant dans `server/academie-registre.js` : il 1727 * refuse une source branchée par un outil que le serveur ne sait pas 1728 * EXÃCUTER, une ressource sans source connue, une licence hors table, et des 1729 * droits envoyés par l'appelant (ils sont DÃRIVÃS de la licence). 1730 * 1731 * TROIS ÃTATS DE LECTURE, ET ILS NE SE RESSEMBLENT PAS : 1732 * · `undefined` â pas encore demandé : « Lecture⦠» ; 1733 * · `null` â demandé et refusé : on DIT pourquoi ; 1734 * · un tableau â le registre. 1735 * Confondre les deux premiers avec le troisième donnerait « aucune source » 1736 * pendant une panne de réseau, et on croirait avoir tout perdu. 1737 */ 1738window.academieChargerSources = async function (force) { 1739 if (STATE.academieSources !== undefined && !force) return 1740 STATE.academieSources = null 1741 try { 1742 const r = await fetch('/api/academie/sources', { headers: internalAuth() }) 1743 if (!r.ok) throw new Error('refus ' + r.status) 1744 const d = await r.json() 1745 STATE.academieSources = Array.isArray(d.sources) ? d.sources : [] 1746 STATE._academieSourcesErreur = '' 1747 /* CE QUE LE SERVEUR A ÃCARTà SE DIT. Une source de départ refusée â un 1748 outil retiré du catalogue, par exemple â rendrait sinon un registre 1749 plus court sans que personne ne sache pourquoi. */ 1750 STATE._academieEcartees = Array.isArray(d.ecartees) ? d.ecartees : [] 1751 } catch (e) { 1752 STATE.academieSources = null 1753 STATE._academieSourcesErreur = e.message === 'refus 401' ? 'connectez-vous pour voir le registre' : e.message 1754 } 1755 render() 1756} 1757 1758/* LA NEWSLETTER PASSE PAR LA PORTE QUI EXISTE. `/api/account/announcements` 1759 pose `announcementsOptIn` sur le compte, et `/api/admin/announcements/send` 1760 n'écrit qu'aux comptes qui l'ont posé â les deux sont écrits, servis et 1761 déjà appelés depuis les Réglages. Ouvrir ici une seconde collecte d'adresses 1762 aurait donné deux listes d'abonnés dont une ne recevrait jamais rien. */ 1763/* LA BASCULE D'UN CANAL. `academieNewsletter` posait un BOOLÃEN de compte 1764 depuis l'Académie, sous le nom « les annonces de l'Académie » : deux noms, 1765 deux promesses, un seul bit â se désabonner « de l'Académie » coupait aussi 1766 les mises à jour du produit, et rien ne le laissait deviner. 1767 Le corps porte `tous` : la liste de ce que « tout » veut dire aujourd'hui. 1768 Sans elle, le serveur ne pourrait pas décocher un canal pour un compte qui 1769 n'a jamais choisi â il partirait d'une liste vide et en couperait quatre. 1770 Et c'est l'écran qui la donne, parce que c'est lui qui tient la table : le 1771 serveur n'a pas de seconde liste à garder d'accord. */ 1772window.annoncesBasculer = async function (canal, oui) { 1773 const corps = canal === null 1774 ? { optIn: oui !== false } 1775 : { canal: canal, abonne: oui !== false, tous: (window.ANNONCES_CANAUX || []).map(c => c.id) } 1776 try { 1777 const r = await fetch('/api/account/announcements', { 1778 method: 'POST', headers: { 'Content-Type': 'application/json', ...internalAuth() }, 1779 body: JSON.stringify(corps), 1780 }) 1781 if (!r.ok) throw new Error('refus ' + r.status) 1782 const d = await r.json().catch(() => ({})) 1783 /* ON RANGE CE QUE LE SERVEUR A DÃCIDÃ, pas ce qu'on a demandé. Les deux 1784 diffèrent dès que la règle a son mot à dire â un canal coché alors que 1785 l'interrupteur général est coupé, par exemple â et afficher son propre 1786 souhait ferait croire à un abonnement qui n'existe pas. */ 1787 STATE.backendUser = STATE.backendUser || {} 1788 STATE.backendUser.announcementsOptIn = d.optIn !== false 1789 if (Array.isArray(d.canaux)) STATE.backendUser.announcementsCanaux = d.canaux 1790 } catch (e) { 1791 /* LE REFUS SE DIT. Sans compte la route rend 401 et rien ne bascule â mais 1792 une case qui ne bouge pas se lit comme un clic raté, pas comme un refus. 1793 On nomme la cause. */ 1794 logNotice('Connectez-vous pour choisir vos annonces.', 'error', 'ð') 1795 } 1796 render() 1797} 1798 1799 1800function _academieCarteWebinaire(w) { 1801 const e = ACADEMIE_ETAT_SEANCE[w.etat] || ACADEMIE_ETAT_SEANCE.rediffusion 1802 const quand = new Date(w.quand)
1803 const lisible = isNaN(quand) ? '' : quand.toLocaleString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) 1804 const cherche = escHtml((w.titre + ' ' + (w.quoi || '') + ' seance directe webinaire ' + (w.matiere || '')).toLowerCase()) 1805 /* ON S'INSCRIT à CE QUI N'A PAS ENCORE EU LIEU, ON REGARDE CE QUI EST PASSÃ. 1806 Un bouton « je participe » sur une rediffusion promettrait une place à une 1807 séance qui n'existe plus. */ 1808 const passe = w.etat === 'rediffusion' 1809 return `<div data-academie-cherche="${cherche}" class="flex flex-col rounded-2xl overflow-hidden" style="background:var(--bg-secondary);border:1px solid ${w.etat === 'en-cours' ? 'var(--danger)' : 'var(--border)'}"> 1810 <div class="flex items-center gap-2 px-3.5 pt-3.5"> 1811 <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[max(0.6875rem,11px)] font-bold" style="background:var(--bg-input);color:${e.couleur};border:1px solid ${e.couleur}">${w.etat === 'en-cours' ? '<span style="width:6px;height:6px;border-radius:999px;background:var(--danger);display:inline-block"></span>' : ''}${escHtml(e.label)}</span> 1812 ${lisible ? `<span class="text-[max(0.6875rem,11px)]" style="color:var(--t-faint)">${escHtml(lisible)}</span>` : ''} 1813 ${w.matiere ? `<span class="ml-auto text-[max(0.6875rem,11px)]" style="color:var(--t-faint)">${escHtml(w.matiere)}</span>` : ''} 1814 </div> 1815 <div class="px-3.5 pt-2 pb-3.5 flex-1"> 1816 <p class="text-[max(0.9375rem,15px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(w.titre)}</p> 1817 ${w.quoi ? `<p class="text-[max(0.8125rem,13px)] mt-1 leading-snug" style="color:var(--t-dim)">${escHtml(w.quoi)}</p>` : ''} 1818 <p class="text-[max(0.75rem,12px)] mt-2" style="color:var(--t-faint)">${w.minutes} min${(w.par || []).length ? ' · ' + escHtml(w.par.join(', ')) : ''} · ${w.inscrits} inscrit${w.inscrits > 1 ? 's' : ''}</p> 1819 </div> 1820 <div class="flex items-center gap-2 px-3.5 pb-3.5"> 1821 ${passe 1822 ? (w.lien ? `<a href="${escHtml(w.lien)}" target="_blank" rel="noopener noreferrer" class="rounded-full px-3.5 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary);text-decoration:none">Voir la rediffusion</a>` : `<span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">Rediffusion à venir</span>`) 1823 : `<button onclick="window.academieInscription('${escHtml(w.id)}')" class="rounded-full px-3.5 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="${w.jeParticipe ? 'background:var(--bg-input);border:1px solid var(--accent);color:var(--accent)' : 'background:var(--accent);color:#fff;border:none'}">${w.jeParticipe ? 'Inscrit â' : 'Je participe'}</button>`} 1824 ${w.jAnime ? `<button onclick="window.academieAnnulerSeance('${escHtml(w.id)}')" class="ml-auto text-[max(0.75rem,12px)] font-semibold" style="color:var(--danger);background:none;border:none">Annuler</button>` : ''} 1825 </div> 1826 </div>` 1827} 1828 1829 1830function _academieFormulaireSeance() { 1831 if (!STATE.academieSeanceFormOuvert) { 1832 return `<button onclick="window.academieSeanceForm(true)" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Proposer une séance</button>` 1833 } 1834 /* LA DATE EST UN CHAMP `datetime-local`, PAS DU TEXTE LIBRE. « le 12 » ne se 1835 compare pas à maintenant : l'état de la séance â à venir, en direct, 1836 rediffusion â se calcule sur cette date, et il se calculerait faux. */ 1837 const champ = (id, lab, type, ph, val, liste) => `<label class="flex flex-col gap-1"> 1838 <span class="text-[max(0.6875rem,11px)] font-semibold" style="color:var(--t-dim)">${escHtml(lab)}</span> 1839 <input id="${id}" type="${type}" ${liste ? `list="${liste}"` : ''} ${val ? `value="${escHtml(val)}"` : ''} placeholder="${escHtml(ph || '')}" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border)" /> 1840 </label>` 1841 /* LA MATIÃRE SE PROPOSE, ELLE NE SE DEVINE PAS. 1842 C'est ce champ qui décide si la séance apparaîtra sur la page de sa 1843 matière : le rapprochement se fait sur le NOM. Laissé en texte libre nu, 1844 « code route » et « Code de la route » ne se rejoignent jamais, et la 1845 séance reste orpheline sans que personne ne comprenne pourquoi. 1846 Une `datalist` PROPOSE les cinquante-six matières réelles sans INTERDIRE 1847 d'écrire autre chose â une séance sur un sujet que l'Ãcole ne couvre pas 1848 encore doit rester possible, et elle garde sa place dans la rangée de 1849 l'accueil. */ 1850 const matieres = `<datalist id="seance-matieres">${(window.ECOLE_MATIERES || []).map(m =>
1850 `<option value="${escHtml(m.nom)}"></option>`).join('')}</datalist>` 1851 return `<div class="rounded-2xl p-4 mb-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 1852 <p class="text-[max(0.875rem,14px)] font-bold mb-3" style="color:var(--t-primary)">Proposer une séance en direct</p> 1853 <div class="grid gap-2.5 mb-3" style="grid-template-columns:repeat(auto-fill,minmax(200px,1fr))"> 1854 ${champ('seance-titre', 'Titre', 'text', 'Ce que la séance apprend')} 1855 ${champ('seance-quand', 'Quand', 'datetime-local', '')} 1856 ${champ('seance-minutes', 'Durée en minutes', 'number', '45', '45')} 1857 ${champ('seance-matiere', 'Matière', 'text', 'Code de la route, Pythonâ¦', '', 'seance-matieres')}${matieres} 1858 ${champ('seance-par', 'Animée par', 'text', 'Votre nom')} 1859 ${champ('seance-lien', 'Lien de la visio (facultatif)', 'text', 'https://â¦')} 1860 </div> 1861 ${champ('seance-quoi', 'Ce que les gens sauront faire à la fin', 'text', 'En une phrase')} 1862 <div class="flex gap-2 mt-3"> 1863 <button onclick="window.academieProposerSeance()" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Poser la séance</button> 1864 <button onclick="window.academieSeanceForm(false)" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">Annuler</button> 1865 </div> 1866 </div>` 1867} 1868 1869function _academieWebinaires() { 1870 const liste = STATE.academieSeances 1871 const refus = STATE._academieRefus 1872 ? `<p class="rounded-xl px-3 py-2 mb-3 text-[max(0.8125rem,13px)]" style="background:color-mix(in srgb,var(--danger) 10%,transparent);border:1px solid var(--danger);color:var(--danger)">${escHtml(STATE._academieRefus)}</p>` 1873 : '' 1874 /* TROIS ÃTATS DE LECTURE, ET ILS NE SE RESSEMBLENT PAS. `undefined` : pas 1875 encore demandé. `null` : demandé et refusé â on DIT pourquoi. Un tableau : 1876 la liste, même vide. Les confondre ferait passer une panne de réseau pour 1877 une académie sans vie. */ 1878 if (liste === undefined) return refus + `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--t-dim)">Lecture des séancesâ¦</p>` 1879 if (liste === null) return refus + `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--danger)">Séances indisponibles${STATE._academieSeancesErreur ? ' â ' + escHtml(STATE._academieSeancesErreur) : ''}.</p>` 1880 1881 if (!liste.length) { 1882 /* L'ÃTAT VIDE DIT « IL N'Y EN A PAS ENCORE », JAMAIS « IL N'Y EN A PAS ». 1883 Les deux se ressemblent et ne veulent pas dire la même chose : l'un 1884 invite, l'autre ferme. Et maintenant l'invitation MÃNE quelque part. */ 1885 return refus + _academieFormulaireSeance() + `<div class="rounded-2xl p-6 text-center mt-3" style="background:var(--bg-secondary);border:1px dashed var(--border)"> 1886 <p class="text-[max(0.9375rem,15px)] font-bold" style="color:var(--t-primary)">Aucune séance programmée pour l'instant</p> 1887 <p class="text-[max(0.8125rem,13px)] mt-1" style="color:var(--t-dim)">Les séances en direct s'afficheront ici avec leur date, et leur rediffusion ensuite. La première peut être la vôtre.</p> 1888 </div>` 1889 } 1890 return refus + _academieFormulaireSeance() 1891 + `<div class="grid gap-2.5 mt-3" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">${liste.map(_academieCarteWebinaire).join('')}</div>` 1892} 1893 1894 1895/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1896 * LE BLOC DES ANNONCES â PARTAGÃ, JAMAIS RECOPIà 1897 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1898 * 1899 * Il s'appelait `_academieNewsletterBloc` et disait « les annonces de 1900 * l'Académie ». Signalé : « il faut centraliser ça parce que ça ne va pas 1901 * jouer un rôle que pour cet espace-là . » 1902 * 1903 * Il prend maintenant un ESPACE et rend les canaux que la table déclare pour 1904 * lui (src/crea-annonces.js) : un espace qui veut annoncer quelque chose 1905 * ajoute une LIGNE à la table, il n'écrit pas un second bloc. Sans espace, il 1906 * rend TOUS les canaux â c'est la forme qu'emploient les Réglages, l'endroit 1907 * où l'on vient gérer ses emails plutôt que les découvrir. 1908 * 1909 * ET IL DIT Oà VIVE LE RESTE. Un espace qui montre deux canaux
1909sur cinq sans 1910 * le dire laisse croire qu'il n'y en a que deux â et le jour où l'on veut se 1911 * désabonner de tout, on cherche là où l'on avait coché. 1912 */ 1913function _annoncesBloc(espace) { 1914 const canaux = espace ? window.annoncesCanauxDeLEspace(espace) : (window.ANNONCES_CANAUX || []) 1915 if (!canaux.length) return '' 1916 const general = !STATE.backendUser || STATE.backendUser.announcementsOptIn !== false 1917 const ligne = (c) => { 1918 const coche = general && window.annoncesAbonneA(c.id) 1919 return `<div class="flex items-center gap-3 px-4 py-3" style="border-top:1px solid var(--border-dim)"> 1920 <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style="background:var(--bg-input);color:var(--t-secondary)">${_icon(c.icone, 'w-4 h-4')}</span> 1921 <div class="min-w-0 flex-1"> 1922 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">${escHtml(c.label)}</p> 1923 <p class="text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-dim)">${escHtml(c.quoi)}</p> 1924 </div> 1925 <button onclick="window.annoncesBasculer('${escHtml(c.id)}',${coche ? 'false' : 'true'})" class="shrink-0 rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="${coche ? 'background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)' : 'background:var(--t-primary);color:var(--bg-primary);border:none'}">${coche ? 'Abonné' : 'Sâabonner'}</button> 1926 </div>` 1927 } 1928 /* L'INTERRUPTEUR GÃNÃRAL EST DIT QUAND IL EST COUPÃ, ET SEULEMENT LÃ. Coupé, 1929 il rend les cases inertes : les laisser paraître cochables ferait cliquer 1930 dans le vide, et on chercherait la panne du côté du bouton. */ 1931 const coupe = !general ? `<div class="px-4 py-3 text-[max(0.8125rem,13px)]" style="background:var(--bg-input);color:var(--t-dim);border-top:1px solid var(--border-dim)"> 1932 Vous avez coupé toutes les annonces de CREA. ${espace ? 'Elles se rallument dans Réglages â Compte.' : ''} 1933 <button onclick="window.annoncesBasculer(null,true)" class="ml-1 font-semibold" style="background:none;border:none;color:var(--accent)">Les rallumer</button> 1934 </div>` : '' 1935 return `<div class="rounded-2xl overflow-hidden mb-9" style="background:var(--bg-secondary);border:1px solid var(--border)"> 1936 <div class="px-4 pt-4 pb-1"> 1937 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Les annonces de CREA</p> 1938 <p class="text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-dim)">Envoyées à lâadresse de votre compte, et le désabonnement tient en un clic.${espace ? ` ${canaux.length} ${canaux.length > 1 ? 'canaux concernent' : 'canal concerne'} cet espace sur ${(window.ANNONCES_CANAUX || []).length} â la liste entière est dans Réglages â Compte.` : ''}</p> 1939 </div> 1940 ${coupe}${canaux.map(ligne).join('')} 1941 </div>` 1942} 1943 1944 1945 1946/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1947 * L'ÃCRAN DU REGISTRE â LE TUYAU AVAIT UNE PIÃCE SANS PORTE 1948 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1949 * 1950 * Le registre était branché et AVEUGLE : on pouvait importer un cours Open edX, 1951 * et rien dans l'Académie ne le montrait. Aucun écran pour ajouter une source, 1952 * aucun pour renseigner une licence â donc tout ce qui entrait restait en 1953 * « inconnue », c'est-à -dire lecture et citation, pour toujours. 1954 * 1955 * ââ CE QUE CET ÃCRAN PEUT CASSER, ET QUI NE LÃVERAIT RIEN âââââââââââââââââ 1956 * 1957 * · UN REFUS AVALÃ. Le serveur refuse avec un code et une phrase â source 1958 * branchée sans outil, licence hors table, ressource orpheline. Les taire 1959 * ferait disparaître la ligne sans un mot, et on la reposerait deux fois 1960 * en croyant à un raté de réseau. Chaque refus s'affiche, tel quel. 1961 * · UNE LICENCE CHANGÃE SANS RIEN DIRE. Passer une ressource de « inconnue » 1962 * à « CC BY » donne le droit de la traduire et de la redistribuer. Ce 1963 * n'est pas un réglage d'affichage : l'écran montre ce que le choix 1964 * PERMET, avant de le poser. 1965 * · UN COMPTE JUSTE AU MAUVAIS ENDROIT. Le nombre de ressources par source 1966 * vient du serveur AVANT filtrage : compté après, il dirait zéro partout 1967 * sauf sur la source ouverte. 1968 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1969 1970window.academieChargerRessources = async function (source) { 1971 STATE.academieRessources = undefined 1972 render() 1973 try { 1974 const q = source ? '?source=' + encodeURIComponent(source) : '' 1975 const r = await fetch('/api/academie/ressources' + q, { headers: internalAuth() }) 1976 if (!r.ok) throw new Error('refus ' + r.status) 1977 const d = await r.json() 1978 STATE.academieRessources = Array.isArray(d.ressources) ? d.ressources : [] 1979 STATE.academieParSource = d.parSource || {} 1980 STATE.academieLicences = d.licences || {} 1981 STATE._academieRegistreErreur = '' 1982 } catch (e) { 1983 STATE.academieRessources = null 1984 STATE._academieRegistreErreur = e.message === 'refus 401' ? 'connectez-vous pour voir le registre' : e.message 1985 } 1986 render() 1987} 1988 1989/* UN REFUS DU SERVEUR S'AFFICHE, IL NE SE DEVINE PAS. `raison` est un code 1990 stable, `detail` la phrase écrite pour être lue â on rend les deux. */ 1991async function _academiePoster(chemin, corps) { 1992 try { 1993 const r = await fetch(chemin, { 1994 method: 'POST', headers: { 'Content-Type': 'application/json', ...internalAuth() }, 1995 body: JSON.stringify(corps), 1996 }) 1997 const d = await r.json().catch(() => ({})) 1998 if (!r.ok || d.ok === false) { 1999 STATE._academieRefus = d.detail ? `${d.raison || 'refus'} â ${d.detail}` : `le serveur a répondu ${r.status}` 2000 return null 2001 } 2002 STATE._academieRefus = '' 2003 return d 2004 } catch (e) { 2005 STATE._academieRefus = e.message 2006 return null 2007 } 2008} 2009 2010window.academieAjouterSource = async function () { 2011 const val = (id) => (document.getElementById(id) || {}).value || '' 2012 const nom = val('reg-nom').trim() 2013 if (!nom) { STATE._academieRefus = 'donnez un nom à la source'; render(); return } 2014 const d = await _academiePoster('/api/academie/sources', { 2015 id: val('reg-id').trim() || nom, 2016 nom, 2017 quoi: val('reg-quoi').trim(), 2018 base: val('reg-base').trim(), 2019 licence: val('reg-licence'), 2020 /* UNE SOURCE AJOUTÃE à LA MAIN EST « à BRANCHER », ET L'ÃCRAN NE PROPOSE 2021 PAS AUTRE CHOSE. « Branchée » exige de nommer un outil que le serveur 2022 sait exécuter : ça ne se déclare pas depuis un formulaire, ça s'écrit 2023 dans le code. Offrir le choix ferait poser une promesse que le serveur 2024 refuserait â un aller-retour pour rien. */ 2025 etat: 'a-brancher', 2026 }) 2027 if (d) { STATE.academieSources = undefined; window.academieChargerSources(true) } 2028 else render() 2029} 2030 2031window.academieLicence = async function (id, licence) { 2032 const r = (STATE.academieRessources || []).find(x => x.id === id) 2033 if (!r) return 2034 /* ON RENVOIE LA RESSOURCE ENTIÃRE, pas seulement la licence : la route 2035 remplace par identifiant, et n'envoyer qu'un champ effacerait les autres. 2036 Les DROITS, eux, ne sont pas envoyés â le serveur les dérive de la 2037 licence, et les poster ne servirait qu'à essayer de se les donner. */ 2038 const d = await _academiePoster('/api/academie/ressources', { 2039 id: r.id, titre: r.titre, type: r.type, sourceId: r.sourceId, 2040 quoi: r.quoi, url: r.url, langue: r.langue, auteur: r.auteur, licence, 2041 }) 2042 /* LE CATALOGUE SUIT LA LICENCE. Changer une licence dans l'administration 2043 change ce que la FICHE affiche au client â « lecture et citation 2044 seulement » devient « traduire, adapter ». Ne rafraîchir que l'écran 2045 d'administration laisserait la boutique afficher l'ancien droit jusqu'au 2046 prochain rechargement complet, et personne ne verrait le désaccord. */ 2047 if (d) { 2048 window.academieChargerRessources(STATE.academieSourceOuverte || null) 2049 window.academieChargerImportees(true) 2050 } else render() 2051} 2052 2053/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
2054 * IMPORTER UN COURS â LE GESTE QUI N'EXISTAIT NULLE PART 2055 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2056 * 2057 * `/api/academie/{openedx|moodle}/importer` était écrite, gardée, éprouvée â 2058 * et personne ne l'appelait. Aucun écran, aucun bouton, aucun outil. Brancher 2059 * son instance ne donnait donc RIEN, et c'est indiscernable d'un connecteur en 2060 * panne : on a fait la moitié du chemin et on a laissé la porte sans poignée. 2061 * 2062 * Deux gestes, et ils sont dans cet ordre parce qu'on n'importe pas à 2063 * l'aveugle : on cherche dans le catalogue de l'instance, on voit ce qu'on 2064 * ramène, puis on l'importe. Le refus du fournisseur â jeton invalide, 2065 * instance non configurée â s'affiche tel quel. 2066 */ 2067const ACADEMIE_FOURNISSEURS = ['openedx', 'moodle'] 2068 2069window.academieChercherCours = async function (fournisseur, q) { 2070 if (!ACADEMIE_FOURNISSEURS.includes(fournisseur)) return 2071 STATE.academieRecherche = { fournisseur, q: q || '', cours: undefined, refus: '' } 2072 render() 2073 try { 2074 const r = await fetch(`/api/academie/${fournisseur}/catalogue?q=` + encodeURIComponent(q || ''), { headers: internalAuth() }) 2075 const d = await r.json().catch(() => ({})) 2076 if (!r.ok || d.ok === false) { 2077 STATE.academieRecherche = { fournisseur, q: q || '', cours: null, refus: d.detail ? `${d.raison || 'refus'} â ${d.detail}` : `le serveur a répondu ${r.status}` } 2078 } else { 2079 STATE.academieRecherche = { fournisseur, q: q || '', cours: d.cours || [], refus: '', filtreLocal: d.filtreLocal === true } 2080 } 2081 } catch (e) { 2082 STATE.academieRecherche = { fournisseur, q: q || '', cours: null, refus: e.message } 2083 } 2084 render() 2085} 2086 2087window.academieImporterCours = async function (fournisseur, cours) { 2088 if (!ACADEMIE_FOURNISSEURS.includes(fournisseur) || !cours) return 2089 STATE.academieImportEnCours = String(cours) 2090 render() 2091 const d = await _academiePoster(`/api/academie/${fournisseur}/importer`, { cours: String(cours) }) 2092 STATE.academieImportEnCours = null 2093 if (d) { 2094 /* LES DEUX LECTURES SE REFONT. L'écran d'administration montre ce qui 2095 vient d'entrer ; le catalogue de la boutique doit le montrer AUSSI, 2096 sinon on aurait importé un cours qui n'existe que pour le propriétaire â 2097 c'est-à -dire exactement le défaut qu'on est en train de refermer. */ 2098 window.academieChargerRessources(STATE.academieSourceOuverte || null) 2099 window.academieChargerImportees(true) 2100 /* LE RETOUR EST à L'ENDROIT DU CLIC, PAS DANS LE JOURNAL. La liste juste 2101 en dessous se remplit â mais un cours dont toutes les sections sont 2102 vides pose ZÃRO ressource, et là rien ne bougerait : le clic aurait 2103 l'air de rater. On dit donc le compte, y compris quand il vaut zéro. */ 2104 STATE.academieDernierImport = { cours: d.cours || cours, posees: d.posees || 0, refusees: (d.refusees || []).length } 2105 } else render() 2106} 2107 2108window.academieOuvrirSource = function (id) { 2109 STATE.academieSourceOuverte = id || null 2110 window.academieChargerRessources(id || null) 2111} 2112 2113/* CE QU'UNE LICENCE PERMET, EN UNE LIGNE. C'est la seule information qui 2114 compte au moment de la choisir : « CC BY-ND » ne dit rien à personne, 2115 « traduire : non » si. */ 2116function _academieDroitsLisibles(d) { 2117 if (!d) return 'droits inconnus' 2118 const permis = [d.traduire && 'traduire', d.adapter && 'adapter', d.redistribuer && 'redistribuer', d.commercial && 'usage commercial'].filter(Boolean) 2119 return (permis.length ? 'Permis : ' + permis.join(', ') : 'Lecture et citation seulement') + (d.attribution ? ' · attribution obligatoire' : '') 2120} 2121 2122function _academieFormulaireSource() { 2123 const licences = STATE.academieLicences || {} 2124 const options = Object.keys(licences).length 2125 ? Object.entries(licences).map(([id, l]) => `<option value="${escHtml(id)}"${id === 'inconnue' ? ' selected' : ''}>${escHtml(l.nom || id)}</option>`).join('') 2126 : '<option value="inconnue">Inconnue</option>' 2127 const champ = (id, invite, large) => `<input id="${id}" placeholder="${escHtml(invite)}" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] outline-none" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary);${large ? 'grid-column:1/-1' : ''}" />` 2128 return `<details class="rounded-2xl p-4 mb-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2129 <summary class="text-[max(0.875rem,14px)] font-bold cursor-pointer" style="color:var(--t-primary)">Ajouter une source</summary> 2130 <p class="text-[max(0.75rem,12px)] mt-1 mb-3" style="color:var(--t-dim)">Elle entre « à brancher » : dire qu'une source est branchée demande de nommer un outil que le serveur sait exécuter, et ça s'écrit dans le code, pas dans un formulaire.</p> 2131 <div class="grid gap-2 mb-3" style="grid-template-columns:1fr 1fr"> 2132 ${champ('reg-nom', 'Nom â ex. Manuels de mon école')} 2133 ${champ('reg-id', 'Identifiant (facultatif)')} 2134 ${champ('reg-quoi', 'Ce quâon y trouve', true)} 2135 ${champ('reg-base', 'Adresse (facultatif)', true)} 2136 <select id="reg-licence" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] outline-none" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary);grid-column:1/-1">${options}</select> 2137 </div> 2138 <button onclick="window.academieAjouterSource()" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ajouter</button> 2139 </details>` 2140} 2141 2142function _academieLigneRessource(r) { 2143 const licences = STATE.academieLicences || {}
2144 const options = Object.keys(licences).length 2145 ? Object.entries(licences).map(([id, l]) => `<option value="${escHtml(id)}"${id === r.licence ? ' selected' : ''}>${escHtml(l.nom || id)}</option>`).join('') 2146 : `<option>${escHtml(r.licence || 'inconnue')}</option>` 2147 return `<div class="rounded-2xl p-3.5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2148 <div class="flex items-start gap-3"> 2149 <div class="min-w-0 flex-1"> 2150 <p class="text-[max(0.875rem,14px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(r.titre)}</p> 2151 <p class="text-[max(0.75rem,12px)] mt-0.5 leading-snug" style="color:var(--t-dim)">${escHtml(r.quoi || '')}</p> 2152 <p class="text-[max(0.6875rem,11px)] mt-1" style="color:var(--t-faint)">${escHtml(r.type)} · ${escHtml(r.sourceId)} · ${escHtml(r.langue || '')}${r.auteur ? ' · ' + escHtml(r.auteur) : ''}</p> 2153 ${/* CE QUE LA LICENCE PERMET, SOUS LE CHOIX. Changer « inconnue » en 2154 « CC BY » donne le droit de traduire et de redistribuer : ce n'est 2155 pas un réglage d'affichage, et l'écran le dit avant le clic. */''} 2156 <p class="text-[max(0.6875rem,11px)] mt-1" style="color:var(--t-faint)">${escHtml(_academieDroitsLisibles(r.droits))}</p> 2157 </div> 2158 <div class="shrink-0 flex flex-col items-end gap-1.5"> 2159 <select onchange="window.academieLicence('${escHtml(r.id)}', this.value)" class="rounded-lg px-2 py-1 text-[max(0.75rem,12px)] outline-none" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary)">${options}</select> 2160 ${r.url ? `<a href="${escHtml(r.url)}" target="_blank" rel="noopener" class="text-[max(0.6875rem,11px)]" style="color:var(--accent)">Ouvrir â</a>` : ''} 2161 </div> 2162 </div> 2163 </div>` 2164} 2165 2166/* L'IMPORTATEUR â VISIBLE SUR LA SOURCE OUVERTE, ET SEULEMENT SI ELLE SERT. 2167 * 2168 * Trois états, trois écrans, et ils ne se ressemblent pas : une source qui 2169 * n'est pas un fournisseur de cours n'a rien à importer ; une source à 2170 * configurer dit ce qui manque, NOMMÃMENT ; une source branchée cherche. Un 2171 * champ de recherche affiché sur une instance non configurée rendrait un 2172 * catalogue vide, et « vide » se lit comme « il n'y a rien là -bas ». 2173 */ 2174function _academieImportateur(sources, ouverte) { 2175 const src = (sources || []).find(x => x && x.id === ouverte) 2176 if (!src || !ACADEMIE_FOURNISSEURS.includes(src.id)) return '' 2177 if (src.etat !== 'connectee') { 2178 return `<div class="rounded-2xl p-4 mb-4" style="background:var(--bg-secondary);border:1px dashed var(--warn)"> 2179 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">${escHtml(src.nom)} n'est pas encore joignable</p> 2180 <p class="text-[max(0.8125rem,13px)] mt-1" style="color:var(--t-dim)">Le connecteur est écrit et éprouvé â il manque l'adresse de l'instance${src.id === 'moodle' ? ' et son jeton de service web' : ''}. Renseignez ${escHtml(src.id === 'moodle' ? 'CREA_MOODLE_BASE et CREA_MOODLE_TOKEN' : 'CREA_OPENEDX_BASE')} sur le serveur, puis rechargez.</p> 2181 </div>` 2182 } 2183 const rech = STATE.academieRecherche && STATE.academieRecherche.fournisseur === src.id ? STATE.academieRecherche : null 2184 const resultats = !rech ? '' 2185 : rech.cours === undefined ? `<p class="text-[max(0.8125rem,13px)] py-2" style="color:var(--t-dim)">Lecture du catalogueâ¦</p>` 2186 : rech.cours === null ? `<p class="text-[max(0.8125rem,13px)] py-2" style="color:var(--danger)">${escHtml(rech.refus || 'catalogue indisponible')}</p>` 2187 : !rech.cours.length ? `<p class="text-[max(0.8125rem,13px)] py-2" style="color:var(--t-faint)">Aucun cours ne correspond dans cette instance.</p>` 2188 : `<div class="space-y-1.5 mt-2">${rech.cours.slice(0, 25).map(c => { 2189 const enCours = STATE.academieImportEnCours === String(c.id) 2190 return `<div class="flex items-center gap-3 rounded-xl px-3 py-2" style="background:var(--bg-primary);border:1px solid var(--border)"> 2191 <div class="min-w-0 flex-1"> 2192 <p class="text-[max(0.8125rem,13px)] font-semibold truncate" style="color:var(--t-primary)">${escHtml(c.titre || c.id)}</p> 2193 <p class="text-[max(0.6875rem,11px)] truncate" style="color:var(--t-faint)">
2193${escHtml([c.id, c.langue, c.categorie].filter(Boolean).join(' · '))}</p> 2194 </div> 2195 <button onclick="window.academieImporterCours('${escHtml(src.id)}','${escHtml(String(c.id))}')" ${enCours ? 'disabled' : ''} class="shrink-0 rounded-full px-3 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="${enCours ? 'background:var(--bg-input);color:var(--t-faint);border:1px solid var(--border)' : 'background:var(--accent);color:#fff;border:none'}">${enCours ? 'Importâ¦' : 'Importer'}</button> 2196 </div>` 2197 }).join('')}</div>` 2198 return `<div class="rounded-2xl p-4 mb-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2199 <p class="text-[max(0.875rem,14px)] font-bold mb-1" style="color:var(--t-primary)">Importer un cours depuis ${escHtml(src.nom)}</p> 2200 <p class="text-[max(0.75rem,12px)] mb-3" style="color:var(--t-dim)">Le cours entre découpé en ressources â une par section â avec sa licence à renseigner ci-dessous.</p> 2201 <div class="flex gap-2"> 2202 <input id="academie-cours-q" value="${escHtml(rech ? rech.q : '')}" onkeydown="if(event.key==='Enter')window.academieChercherCours('${escHtml(src.id)}',this.value)" placeholder="Chercher un cours â vide = tout le catalogue" class="flex-1 min-w-0 rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border)" /> 2203 <button onclick="window.academieChercherCours('${escHtml(src.id)}',document.getElementById('academie-cours-q').value)" class="shrink-0 rounded-lg px-3.5 py-2 text-[max(0.8125rem,13px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">Chercher</button> 2204 </div> 2205 ${rech && rech.filtreLocal && rech.q ? `<p class="text-[max(0.6875rem,11px)] mt-1.5" style="color:var(--t-faint)">Le filtre est appliqué ici, pas par l'instance : elle rend tout son catalogue visible.</p>` : ''} 2206 ${STATE.academieDernierImport ? `<p class="text-[max(0.75rem,12px)] mt-2" style="color:${STATE.academieDernierImport.posees ? 'var(--success)' : 'var(--warn)'}">« ${escHtml(STATE.academieDernierImport.cours)} » : ${STATE.academieDernierImport.posees} ressource${STATE.academieDernierImport.posees > 1 ? 's' : ''} dans l'Académie${STATE.academieDernierImport.posees ? '' : ' â ce cours nâa aucune section remplie'}${STATE.academieDernierImport.refusees ? ` · ${STATE.academieDernierImport.refusees} refusée(s) par le registre` : ''}.</p>` : ''} 2207 ${resultats} 2208 </div>` 2209} 2210 2211function _academieRegistre() { 2212 const sources = STATE.academieSources 2213 const ouverte = STATE.academieSourceOuverte || null 2214 const parSource = STATE.academieParSource || {} 2215 const refus = STATE._academieRefus 2216 ? `<p class="rounded-xl px-3 py-2 mb-3 text-[max(0.8125rem,13px)]" style="background:color-mix(in srgb,var(--danger) 10%,transparent);border:1px solid var(--danger);color:var(--danger)">${escHtml(STATE._academieRefus)}</p>` 2217 : '' 2218 if (sources === undefined) return refus + `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--t-dim)">Lecture du registreâ¦</p>` 2219 if (sources === null) return refus + `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--danger)">Registre indisponible${STATE._academieSourcesErreur ? ' â ' + escHtml(STATE._academieSourcesErreur) : ''}.</p>` 2220 2221 const cartes = `<div class="grid gap-2 mb-6" style="grid-template-columns:repeat(auto-fill,minmax(220px,1fr))"> 2222 <button onclick="window.academieOuvrirSource('')" class="rounded-2xl p-3 text-left" style="background:var(--bg-secondary);border:1px solid ${ouverte ? 'var(--border)' : 'var(--accent)'}"> 2223 <p class="text-[max(0.8125rem,13px)] font-bold" style="color:var(--t-primary)">Toutes les sources</p> 2224 <p class="text-[max(0.6875rem,11px)] mt-0.5" style="color:var(--t-faint)">${Object.values(parSource).reduce((a, b) => a + b, 0)} ressources</p> 2225 </button> 2226 ${sources.map(s => { 2227 const n = parSource[s.id] || 0 2228 const on = s.etat === 'connectee' 2229 return `<button onclick="window.academieOuvrirSource('${escHtml(s.id)}')" class="rounded-2xl p-3 text-left" style="background:var(--bg-secondary);border:1px solid ${ouverte === s.id ? 'var(--accent)' : 'var(--border)'}"> 2230 <p class="text-[max(0.8125rem,13px)] font-bold flex items-center gap-1.5" style="color:var(--t-primary)"><span class="shrink-0 rounded-full" style="width:7px;height:7px;background:${on ? 'var(--success)' : s.etat === 'a-configurer' ? 'var(--warn)' : 'var(--t-faint)'}"></span>${escHtml(s.nom)}</p> 2231 <p class="text-[max(0.6875rem,11px)] mt-0.5" style="color:var(--t-faint)">${n} ressource${n > 1 ? 's' : ''} · ${escHtml(s.licence || 'inconnue')}</p> 2232 </button>` 2233 }).join('')} 2234 </div>` 2235 2236 const liste = STATE.academieRessources 2237 const corps = liste === undefined 2238 ? `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--t-dim)">Lecture des ressourcesâ¦</p>` 2239 : liste === null 2240 ? `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--danger)">Ressources indisponibles${STATE._academieRegistreErreur ? ' â ' + escHtml(STATE._academieRegistreErreur) : ''}.</p>` 2241 : liste.length 2242 ? `<div class="space-y-2">${liste.map(_academieLigneRessource).join('')}</div>` 2243 /* « AUCUNE RESSOURCE » ET « RIEN N'A ENCORE ÃTà IMPORTà » NE SE 2244 RESSEMBLENT PAS. Le registre neuf est vide â le dire, et dire par où 2245 on le remplit, vaut mieux qu'un cadre blanc. */ 2246 : `<div class="rounded-2xl p-5 text-center" style="background:var(--bg-secondary);border:1px dashed var(--border)"> 2247 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Rien d'importé pour l'instant</p> 2248 <p class="text-[max(0.8125rem,13px)] mt-1" style="color:var(--t-dim)">Demandez à CREA de chercher un cours dans une source branchée â elle lit le catalogue et son plan, et vous décidez de ce qui entre ici.</p> 2249 </div>` 2250 2251 /* CE QUE LE SERVEUR A ÃCARTà SE DIT â ET SE DIT ICI DÃSORMAIS. 2252 Cette phrase vivait dans la rangée de l'aperçu, qui a quitté la vue 2253 client. Elle ne s'adresse qu'au propriétaire : une source de départ 2254 refusée (outil retiré du catalogue, par exemple) rendrait sinon un
2255 registre plus court sans que personne ne sache pourquoi. */ 2256 const ecartees = (STATE._academieEcartees || []).length 2257 ? `<p class="text-[max(0.6875rem,11px)] mt-3" style="color:var(--warn)">${(STATE._academieEcartees || []).length} source(s) de départ écartée(s) par le serveur : ${escHtml((STATE._academieEcartees || []).map(x => x.id + ' (' + x.raison + ')').join(', '))}.</p>` 2258 : '' 2259 return refus + _academieFormulaireSource() + cartes + _academieImportateur(sources, ouverte) + corps + ecartees 2260} 2261 2262/* ââ VUE 1 : L'APERÃU âââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2263/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2264 * LES RÃGLAGES DE L'ACADÃMIE â UNE PAGE, PAS UN FOND DE PAGE 2265 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2266 * 2267 * « Administration, le registre, et cetera. Ãa, il faut venir créer une forme 2268 * de page pour ces réalités-là aussi, donc de réglages. Ce n'est pas juste 2269 * des boutons en gros âdéposer mon coursâ. » 2270 * 2271 * C'étaient quatre blocs empilés SOUS le catalogue : le dépôt, la porte du 2272 * registre, la newsletter, le bilan. Il fallait faire défiler toute la page 2273 * pour les voir, et une fois vus ils renvoyaient en haut. Un réglage qui se 2274 * trouve en descendant n'est pas rangé : il traîne. 2275 * 2276 * Ils ont maintenant leur page, atteinte par un bouton d'en-tête, et la 2277 * frontière du registre est celle qui existait déjà (`isClientView`) â la 2278 * chaîne d'approvisionnement ne se montre pas au client. 2279 */ 2280/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2281 * LE BILAN D'APPRENTISSAGE â CE QU'ON A FAIT, ET CE QU'ON A CROISà 2282 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2283 * 2284 * « Publier un bilan d'apprentissage, ça aussi, c'est un autre système encore 2285 * à venir imaginer. » 2286 * 2287 * C'était un bouton « Configurer » qui écrivait une demande en brouillon dans 2288 * le chat : on lui demandait de poser un rappel, et il n'y avait aucun bilan 2289 * derrière. Un rappel de relire quelque chose qui n'existe pas. 2290 * 2291 * Maintenant, une page, et RIEN N'Y EST INVENTÃ. Tout ce qu'elle montre est
2292 * déjà compté ailleurs â les leçons faites, les étapes cochées, et ce que le 2293 * flux a proposé sans qu'on s'y arrête. Le poste (src/crea-apprendre.js) les 2294 * rassemble et ne juge pas ; l'écran les met côte à côte et ne félicite pas. 2295 * Un bilan qui dirait « bon rythme » sur un compteur à zéro serait la première 2296 * chose qu'on cesserait de lire. 2297 * 2298 * LA TROISIÃME COLONNE EST LA PLUS UTILE, et elle n'existait nulle part : ce 2299 * qu'on a CROISà en travaillant sans s'arrêter. C'est exactement ce qu'un 2300 * bilan doit rappeler â pas ce qu'on a fait, ce qu'on a remis à plus tard. 2301 */ 2302function _academieBilan() { 2303 const b = window.apprendreBilan() 2304 if (b.vide) { 2305 return `<div class="rounded-2xl p-6 text-center" style="background:var(--bg-secondary);border:1px dashed var(--border)"> 2306 <p class="text-[max(0.9375rem,15px)] font-bold" style="color:var(--t-primary)">Rien à rapporter pour lâinstant</p> 2307 <p class="text-[max(0.8125rem,13px)] mt-1" style="color:var(--t-dim)">Ce bilan se remplit tout seul : les leçons que vous faites, les étapes que vous cochez, et ce que CREA vous propose pendant que vous travaillez. Il ne devine rien â tant quâil nây a rien, il le dit.</p> 2308 </div>` 2309 } 2310 const carte = (o, sousTitre, clic) => _academieCarteCouchee({ 2311 clic, o, cote: 96, titre: o.nom, quoi: sousTitre, 2312 meta: o.faites + ' sur ' + o.total, part: o.part, 2313 }) 2314 const chiffres = `<div class="grid gap-2.5 mb-4" style="grid-template-columns:repeat(auto-fit,minmax(180px,1fr))"> 2315 ${[['Leçons faites', b.lecons], ['Ãtapes cochées', b.etapes], ['Croisé sans sâarrêter', b.croises.length]].map(([l, n]) => ` 2316 <div class="rounded-2xl px-4 py-3.5" style="background:var(--bg-input);border:1px solid var(--border)"> 2317 <p class="text-[max(1.625rem,26px)] font-bold leading-none" style="color:var(--accent)">${n}</p> 2318 <p class="text-[max(0.75rem,12px)] mt-1.5" style="color:var(--t-faint)">${escHtml(l)}</p> 2319 </div>`).join('')} 2320 </div>` 2321 2322 const matieres = b.matieres.length ? _academieRangee('à lâÃcole', 'Les matières que vous avez ouvertes.', 2323 `<div style="${ACADEMIE_GRILLE}">${b.matieres.map(m => carte(m, '', `window.academieSection('education');window.ecoleVue('matiere','${escHtml(m.id)}')`)).join('')}</div>`) : '' 2324 2325 const parcours = b.parcours.length ? _academieRangee('Dans les tutoriels', 'Les parcours que vous avez commencés.', 2326 `<div style="${ACADEMIE_GRILLE}">${b.parcours.map(p => carte(p, '', `window.academieSection('tutoriels');window.tutoVue('parcours','${escHtml(p.id)}')`)).join('')}</div>`) : '' 2327 2328 const croises = b.croises.length ? _academieRangee('Croisé en travaillant', 'CREA vous lâa proposé pendant que vous faisiez autre chose. Ce nâest pas fait.', 2329 `<div class="rounded-2xl overflow-hidden" style="background:var(--bg-secondary);border:1px solid var(--border)">${b.croises.map((c, i) => ` 2330 <button onclick="window.apprendreOuvrir('${escHtml(c.id)}')" class="w-full text-left flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-black/[.035]" style="background:none;border:none;border-top:${i ? '1px solid var(--border-dim)' : 'none'}"> 2331 <span class="shrink-0" style="color:var(--accent)">${_icon('gradcap', 'w-4 h-4')}</span> 2332 <span class="flex-1 min-w-0"> 2333 <span class="block text-[max(0.90625rem,14.5px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(c.titre)}</span> 2334 <span class="block text-[max(0.78125rem,12.5px)] leading-snug mt-0.5" style="color:var(--t-dim)">${escHtml(c.quoi)}</span> 2335 </span> 2336 <span class="shrink-0 text-[max(0.71875rem,11.5px)] font-semibold" style="color:var(--t-faint)">${c.minutes} min</span> 2337 ${_icon('chevronRight', 'w-4 h-4 shrink-0')} 2338 </button>`).join('')}</div>`) : '' 2339 2340 return chiffres + croises + matieres + parcours 2341} 2342 2343function _academieReglages() { 2344 const bilan = `<button data-studio-template-card="chat" data-template-exemple="${escHtml(ACADEMIE_BILAN)}" class="w-full text-left rounded-2xl p-4 transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2345 <div class="flex items-center gap-3">
2346 <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style="background:var(--bg-input);color:var(--t-secondary)">${_icon('calendar', 'w-4 h-4')}</span> 2347 <div class="min-w-0 flex-1"> 2348 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Planifier un bilan d'apprentissage</p> 2349 <p class="text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-dim)">Choisissez quoi travailler et à quelle fréquence. La demande s'ouvre en brouillon dans le chat : c'est CREA qui pose le rappel, après votre confirmation.</p> 2350 </div> 2351 <span class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">Poser le rappel ${_icon('chevronRight', 'w-3.5 h-3.5')}</span> 2352 </div> 2353 </button>` 2354 /* LE BILAN PASSE DEVANT SON RAPPEL. On venait proposer de POSER UN RAPPEL de 2355 relire un bilan qui n'existait pas : le bouton écrivait une demande en 2356 brouillon dans le chat, et c'était tout le système. Le bilan est 2357 maintenant une page, et le rappel vient après â c'est l'ordre des 2358 choses. */ 2359 return _academieRangee('Votre bilan dâapprentissage', 'Ce que vous avez fait, et ce que vous avez croisé sans vous arrêter.', _academieBilan()) 2360 + _academieRangee('Se poser un rendez-vous', 'Un rappel régulier pour revenir dessus. La demande sâouvre en brouillon dans le chat : câest CREA qui pose le rappel, après votre confirmation.', bilan) 2361 + _annoncesBloc('espaceAcademie') 2362 + _academieBlocDepot() 2363 + _academieAdministration() 2364} 2365 2366/* LE FOURNISSEUR NE SE MONTRE PAS AU CLIENT. 2367 * 2368 * L'aperçu portait une rangée « Où l'Académie va chercher » : sept sources, 2369 * leur état de connexion, l'aveu qu'Open edX est « à brancher », un formulaire 2370 * pour en ajouter une et un sélecteur de licence par ressource. C'est la 2371 * chaîne d'approvisionnement, affichée en vitrine. Aucune maison ne montre à 2372 * ses clients chez qui elle achète, ni lesquels de ses contrats ne sont pas 2373 * signés. 2374 * 2375 * Ce qui est dû au client reste : le CRÃDIT d'une Åuvre â source, auteur, 2376 * licence â vit sur la fiche de la ressource, où il sert. Ce qui relève de 2377 * l'exploitation part derrière cette porte. 2378 * 2379 * `isClientView()` est le partage qui existe déjà dans le tronc (il vaut aussi 2380 * pour l'aperçu client d'un propriétaire) : on ne s'en écrit pas un second. 2381 */ 2382function _academieAdministration() { 2383 if (isClientView()) return '' 2384 return _academieRangee('Administration · le registre des sources', 2385 "Visible du propriétaire seulement. Les sources branchées, ce qui en a été importé, et sous quelle licence.", 2386 `<button onclick="window.academieVue('registre')" class="w-full text-left rounded-2xl p-4 transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px dashed var(--border)"> 2387 <div class="flex items-center gap-3"> 2388 <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style="background:var(--bg-input);color:var(--t-secondary)">${_icon('plug', 'w-4 h-4')}</span> 2389 <div class="min-w-0 flex-1"> 2390 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Ouvrir le registre</p> 2391 <p class="text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-dim)">Brancher une source, importer un cours, renseigner une licence.</p> 2392 </div> 2393 ${_icon('chevronRight', 'w-4 h-4 shrink-0')} 2394 </div> 2395 </button>`) 2396} 2397 2398/* ââ VUE 2 : UN RÃLE ââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2399function _academiePageDeRole(r) { 2400 const prompts = `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">${r.prompts.map(_academieCartePrompt).join('')}</div>` 2401 const cas = _academieCatalogue().filter(x => x.role === r.id) 2402 const grille = `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">${cas.map(_academieCarte).join('')}</div>` 2403 return _academieRangee('Essayer un prompt', "Chaque prompt s'ouvre en brouillon dans son lieu : vous le modifiez avant de l'envoyer.", prompts) 2404 + _academieRangee("Cas d'usage", 'Un vrai enchaînement, étape par étape.', grille, _academieLienTout('ressources')) 2405} 2406 2407/* ââ VUE 3 : UN LIEU ââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2408function _academiePageDeLieu(id) { 2409 const cat = _academieCatalogue().filter(r => r.ou === id) 2410 const commencer = cat.filter(r => r.type !== 'cas') 2411 const taches = cat.filter(r => r.type === 'cas') 2412 const parType = ['cours', 'tutoriels', 'cas'].map(t => ({ t, n: cat.filter(r => r.type === t).length })) 2413 const tout = `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">${parType.map(x => ` 2414 <button onclick="window.academieVue('ressources');window.academieOnglet('${x.t}');window.academieFiltreLieu('${id}')" class="rounded-2xl p-3.5 text-left transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border);${x.n ? '' : 'opacity:.5'}">
2415 <span class="block text-[max(0.875rem,14px)] font-semibold" style="color:var(--t-primary)">${x.t === 'cours' ? 'Cours' : x.t === 'tutoriels' ? 'Tutoriels' : "Cas d'usage"}</span> 2416 <span class="block text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-faint)">${x.n === 0 ? 'aucun pour lâinstant' : x.n + (x.t === 'cours' ? ' cours' : x.t === 'tutoriels' ? (x.n > 1 ? ' tutoriels' : ' tutoriel') : " cas d'usage")}</span> 2417 </button>`).join('')}</div>` 2418 const ouvrir = `<button onclick="window.academieOuvrir('${id}')" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ouvrir ${escHtml(_academieNomDuLieu(id))}</button>` 2419 return (commencer.length ? _academieRangee('Commencer ici', '', `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(240px,1fr))">${commencer.map(_academieCarte).join('')}</div>`) : '') 2420 + (taches.length ? _academieRangee('Essayer une tâche', 'Un enchaînement complet, étape par étape.', `<div class="space-y-2.5">${taches.map(_academieLigne).join('')}</div>`, _academieLienTout('ressources')) : '') 2421 + _academieRangee('Tout', '', tout) 2422 + `<div class="mt-2">${ouvrir}</div>` 2423} 2424 2425/* ââ VUE 4 : TOUTES LES RESSOURCES ââââââââââââââââââââââââââââââââââââââââ */ 2426/* LES ONGLETS SONT DÃRIVÃS, COMME LES LIEUX â ET POUR LA MÃME RAISON. 2427 Une liste écrite à la main a deux façons de mentir, toutes deux invisibles à 2428 la relecture : un onglet qui ne rend rien, ou un type de ressource qu'AUCUN 2429 onglet ne montre. Le second est arrivé dès que l'import a existé : le 2430 registre connaît « exercice », « article », « vidéo », « jeu de données », 2431 et les quatre n'avaient nulle part où aller. Ils seraient entrés dans le 2432 catalogue pour rester introuvables. 2433 « Tout » et « Webinaires » sont là quoi qu'il arrive : le premier ne filtre 2434 rien, le second ne vient pas du catalogue et dit lui-même qu'il est vide. */ 2435const ACADEMIE_ONGLETS_LABELS = { 2436 tout: 'Tout', cours: 'Cours', tutoriels: 'Tutoriels', cas: "Cas d'usage", 2437 exercices: 'Exercices', documents: 'Documents', webinaires: 'Webinaires', 2438} 2439const ACADEMIE_ONGLETS_ORDRE = ['tout', 'cours', 'tutoriels', 'cas', 'exercices', 'documents', 'webinaires'] 2440function _academieOngletsDuCatalogue() { 2441 const presents = new Set(_academieCatalogue().map(r => r.type)) 2442 return ACADEMIE_ONGLETS_ORDRE 2443 .filter(id => id === 'tout' || id === 'webinaires' || presents.has(id)) 2444 .map(id => ({ id, label: ACADEMIE_ONGLETS_LABELS[id] })) 2445} 2446function _academieRessources() { 2447 const onglets_ = _academieOngletsDuCatalogue() 2448 /* UN ONGLET QUI N'EXISTE PLUS RAMÃNE à « TOUT ». Il suffit d'avoir retiré la 2449 dernière ressource importée d'un genre en gardant l'onglet ouvert : le 2450 filtre porterait sur un type que plus rien ne porte, et la page dirait 2451 « Rien ne correspond » sans qu'aucun onglet n'ait l'air sélectionné. */ 2452 const onglet = onglets_.some(o => o.id === (STATE.academieOnglet || 'tout')) ? (STATE.academieOnglet || 'tout') : 'tout' 2453 const lieu = STATE.academieLieu || null 2454 let liste = _academieCatalogue() 2455 if (onglet !== 'tout') liste = liste.filter(r => r.type === onglet) 2456 if (lieu) liste = liste.filter(r => r.ou === lieu) 2457 const onglets = `<div class="flex flex-wrap gap-1.5 mb-3">${onglets_.map(o => ` 2458 <button onclick="window.academieOnglet('${o.id}')" class="rounded-lg px-3 py-1.5 text-[max(0.8125rem,13px)] font-medium" style="${onglet === o.id ? 'background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary)' : 'background:transparent;border:1px solid transparent;color:var(--t-dim)'}">${escHtml(o.label)}</button>`).join('')}</div>` 2459 const filtres = `<div class="flex flex-wrap items-center gap-2 mb-5"> 2460 <span class="inline-flex items-center gap-2 rounded-lg px-3 py-1.5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2461 ${_icon('loupe', 'w-3.5 h-3.5')} 2462 <input id="academie-recherche" oninput="window.academieChercher(this.value)" placeholder="Chercher" class="bg-transparent outline-none text-[max(0.8125rem,13px)]" style="color:var(--t-primary);width:130px" /> 2463 </span> 2464 ${/* QUATORZE PASTILLES DE LIEU DÃBORDAIENT LA LIGNE â mesuré à l'écran : le 2465 compteur était rejeté sous elles, et les derniers lieux se coupaient en 2466 trois mots. Une liste déroulante native tient sur une ligne, se déroule 2467 au clavier, et ne relance pas le rendu tant qu'on ne choisit rien. */''} 2468 <span class="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5" style="background:var(--bg-secondary);border:1px solid var(--border)">
2469 <span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">Lieu</span> 2470 <select onchange="window.academieFiltreLieu(this.value || null)" class="bg-transparent outline-none text-[max(0.8125rem,13px)] font-medium" style="color:var(--t-primary);border:none"> 2471 <option value=""${lieu ? '' : ' selected'}>Tout</option> 2472 ${_academieLieux().map(id => `<option value="${escHtml(id)}"${lieu === id ? ' selected' : ''}>${escHtml(_academieTitreDuLieu(id))}</option>`).join('')} 2473 </select> 2474 </span> 2475 <span id="academie-compteur" class="ml-auto text-[max(0.8125rem,13px)]" style="color:var(--t-faint)">${escHtml(_academieCompte(onglet === 'webinaires' ? (STATE.academieSeances || []).length : liste.length))}</span> 2476 </div>` 2477 const vide = `<p id="academie-vide" class="text-center py-10 text-[max(0.8125rem,13px)]" style="color:var(--t-faint);${liste.length ? 'display:none' : ''}">Rien ne correspond. Essayez un autre mot, ou remettez le lieu sur « Tout ».</p>` 2478 /* LES WEBINAIRES NE SONT PAS DES LIGNES DE CATALOGUE. Une séance a une DATE 2479 et un état â à venir, rediffusion â que la ligne « titre · durée » ne sait 2480 pas porter, et un onglet vide se dit au lieu de rendre une liste vide. */ 2481 if (onglet === 'webinaires') return onglets + filtres + _academieWebinaires() 2482 /* UN CATALOGUE AMPUTà SE DIT. Si la lecture des ressources importées a été 2483 refusée, `_academieImportees()` rend un tableau vide et la page est 2484 simplement PLUS COURTE â sans une erreur, sans un mot. On aurait cherché 2485 un cours importé, ne pas l'avoir trouvé, et conclu que l'import n'avait 2486 rien donné. C'est la différence entre « il n'y en a pas » et « je n'ai pas 2487 pu regarder », et elle ne se devine pas. */ 2488 const amputee = STATE.academieImportees === null 2489 ? `<p class="rounded-xl px-3 py-2 mb-3 text-[max(0.8125rem,13px)]" style="background:color-mix(in srgb,var(--warn) 12%,transparent);border:1px solid var(--warn);color:var(--t-secondary)">Les ressources importées n'ont pas pu être lues â cette liste ne montre que le contenu propre à CREA.</p>` 2490 : '' 2491 return onglets + filtres + amputee + `<div class="space-y-2.5">${liste.map(_academieLigne).join('')}</div>` + vide 2492} 2493 2494/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2495 * LE MOTEUR DE TRANSFORMATION â CÃTà ÃCRAN 2496 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2497 * 2498 * La table des droits s'affichait et ne servait à rien. On lisait « traduire : 2499 * oui » sur une fiche, et aucun geste ne s'en servait. Ici, le droit décide : 2500 * une transformation que la licence refuse n'est pas cachée â elle est 2501 * MONTRÃE, éteinte, avec la phrase qui dit pourquoi. Cacher un refus le ferait 2502 * passer pour une fonction absente. 2503 * 2504 * LE SERVEUR DÃCIDE DEUX FOIS, ET ON NE S'EN PLAINT PAS : ce qui repart d'ici 2505 * a voyagé, et la première autorisation avec. 2506 */ 2507window.academieChargerTransformations = async function (force) { 2508 if (STATE.academieTransformations !== undefined && !force) return 2509 STATE.academieTransformations = null 2510 try { 2511 const r = await fetch('/api/academie/transformations', { headers: internalAuth() }) 2512 if (!r.ok) throw new Error('refus ' + r.status) 2513 const d = await r.json() 2514 STATE.academieTransformations = d.transformations || [] 2515 STATE.academieLicencesDeposables = d.licencesDeposables || [] 2516 } catch (e) { 2517 STATE.academieTransformations = null 2518 } 2519 render() 2520} 2521 2522window.academieViserTransformation = function (ressourceId, transformation) { 2523 STATE.academieTransfo = transformation ? { ressource: ressourceId, transformation, etape: 'choix' } : null 2524 STATE._academieRefus = '' 2525 render() 2526} 2527 2528window.academieLancerTransformation = async function () { 2529 const t = STATE.academieTransfo 2530 if (!t) return 2531 const def = (STATE.academieTransformations || []).find(x => x.id === t.transformation) 2532 const options = {} 2533 if (def && def.parametre) { 2534 const champ = document.getElementById('academie-transfo-param') 2535 options[def.parametre] = (champ && champ.value) || '' 2536 } 2537 t.options = options 2538 t.etape = 'verification' 2539 render() 2540 2541 const v = await _academiePoster('/api/academie/transformer/verifier', { ressource: t.ressource, transformation: t.transformation, options }) 2542 if (!v) { t.etape = 'choix'; render(); return } 2543 2544 /* LE CORPS SE DEMANDE à L'UNITà â la liste ne le porte pas. Une ressource 2545 sans corps stocké (un cours Open edX dont on n'a que le plan) se 2546 transforme quand même : on le DIT à la consigne, au lieu de laisser le 2547 modèle combler le trou sans prévenir. */ 2548 let corps = '' 2549 try { 2550 const r = await fetch('/api/academie/ressources/' + encodeURIComponent(t.ressource), { headers: internalAuth() }) 2551 if (r.ok) { const d = await r.json(); corps = (d.ressource && d.ressource.contenu) || '' } 2552 } catch (e) { corps = '' } 2553 2554 t.etape = 'travail' 2555 t.licenceResultat = v.licenceResultat 2556 t.licenceImposee = v.licenceImposee 2557 render() 2558 2559 let texte = '' 2560 try { 2561 texte = await parlerAuModele({ 2562 messages: [{ role: 'user', content: corps || "Cette ressource n'a pas de corps stocké dans le registre : travaille à partir de son titre et de sa description, et écris explicitement ce qui manque au lieu de le combler." }], 2563 systemPrompt: v.consigne, 2564 ...internalAuth(), 2565 }, { intention: 'utilitaire', surface: 'espaceAcademie', memoire: 'espaceAcademie', timeoutMs: 120000 }) 2566 } catch (e) { 2567 STATE._academieRefus = 'la transformation nâa pas abouti â ' + (e.message || 'erreur inconnue') 2568 t.etape = 'choix'; render(); return 2569 } 2570 2571 const d = await _academiePoster('/api/academie/transformer/poser', { 2572 ressource: t.ressource, transformation: t.transformation, options, contenu: texte, 2573 }) 2574 if (!d) { t.etape = 'choix'; render(); return } 2575 STATE.academieTransfo = null 2576 window.academieChargerImportees(true) 2577 window.academieChargerRessources(STATE.academieSourceOuverte || null) 2578 render() 2579} 2580 2581/* CE QUE LA LICENCE PERMET, ET CE QU'ELLE REFUSE â LES DEUX S'AFFICHENT. */ 2582function _academieBlocTransformer(r) { 2583 const defs = STATE.academieTransformations 2584 if (!Array.isArray(defs) || !defs.length) return '' 2585 const d = r.droits || {} 2586 const enCours = STATE.academieTransfo && STATE.academieTransfo.ressource === r.id ? STATE.academieTransfo : null 2587 const def = enCours ? defs.find(x => x.id === enCours.transformation) : null 2588 2589 const puces = defs.map(t => { 2590 const permis = d[t.droit] === true 2591 const vise = enCours && enCours.transformation === t.id 2592 return `<button ${permis ? `onclick="window.academieViserTransformation('${escHtml(r.id)}','${vise ? '' : escHtml(t.id)}')"` : 'disabled'}
2593 title="${escHtml(permis ? t.quoi : 'La licence « ' + (r.licence || 'inconnue') + ' » ne le permet pas.')}" 2594 class="rounded-full px-3.5 py-1.5 text-[max(0.75rem,12px)] font-semibold" 2595 style="${permis ? (vise ? 'background:var(--accent);color:#fff;border:1px solid var(--accent)' : 'background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)') : 'background:var(--bg-secondary);border:1px dashed var(--border);color:var(--t-faint);cursor:not-allowed'}">${escHtml(t.label)}${permis ? '' : ' ð«'}</button>` 2596 }).join('') 2597 2598 const refuses = defs.filter(t => d[t.droit] !== true) 2599 const pourquoi = refuses.length 2600 ? `<p class="text-[max(0.6875rem,11px)] mt-2" style="color:var(--t-faint)">${escHtml(refuses.map(t => t.label.toLowerCase()).join(', '))} â la licence « ${escHtml(r.licence || 'inconnue')} » ne le permet pas. Renseignez la licence si vous la connaissez.</p>` 2601 : '' 2602 2603 let travail = '' 2604 if (enCours && def) { 2605 if (enCours.etape === 'travail' || enCours.etape === 'verification') { 2606 travail = `<p class="text-[max(0.8125rem,13px)] mt-3" style="color:var(--t-dim)">${enCours.etape === 'verification' ? 'Vérification des droitsâ¦' : 'CREA travaille â le résultat entrera au registre sous « ' + escHtml(enCours.licenceResultat || '') + ' »' + (enCours.licenceImposee ? ', et cette licence est imposée par celle de la source.' : '.')}</p>` 2607 } else { 2608 travail = `<div class="flex flex-wrap items-end gap-2 mt-3"> 2609 ${def.parametre ? `<label class="flex flex-col gap-1"> 2610 <span class="text-[max(0.6875rem,11px)] font-semibold" style="color:var(--t-dim)">${escHtml(def.demandeParametre)}</span> 2611 <input id="academie-transfo-param" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border);width:220px" /> 2612 </label>` : ''} 2613 <button onclick="window.academieLancerTransformation()" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">${escHtml(def.label)}</button> 2614 </div>` 2615 } 2616 } 2617 2618 return `<div class="rounded-2xl p-4 mt-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2619 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Transformer cette ressource</p> 2620 <p class="text-[max(0.75rem,12px)] mt-0.5 mb-2.5" style="color:var(--t-dim)">Ce que vous avez le droit de faire vient de la licence, pas d'un réglage. Le résultat entre au registre comme une Åuvre dérivée, créditée.</p> 2621 <div class="flex flex-wrap gap-2">${puces}</div> 2622 ${pourquoi} 2623 ${travail} 2624 </div>` 2625} 2626 2627/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2628 * LE DÃPÃT â LE SEUL ENDROIT Oà C'EST L'AUTEUR QUI CHOISIT LA LICENCE 2629 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2630window.academieDepotForm = function (ouvert) { STATE.academieDepotOuvert = !!ouvert; STATE._academieRefus = ''; render() } 2631 2632window.academieDeposer = async function () { 2633 const lu = (id) => (document.getElementById(id) || {}).value || '' 2634 const d = await _academiePoster('/api/academie/deposer', { 2635 titre: lu('depot-titre'), quoi: lu('depot-quoi'), auteur: lu('depot-auteur'), 2636 langue: lu('depot-langue'), minutes: Number(lu('depot-minutes')), 2637 licence: lu('depot-licence'), contenu: lu('depot-contenu'), 2638 }) 2639 if (d) { 2640 STATE.academieDepotOuvert = false 2641 window.academieChargerImportees(true) 2642 window.academieChargerRessources(STATE.academieSourceOuverte || null) 2643 } else render() 2644} 2645 2646function _academieBlocDepot() { 2647 const licences = STATE.academieLicencesDeposables || [] 2648 if (!STATE.academieDepotOuvert) {
2649 return _academieRangee('Déposer un cours', "Votre cours entre dans l'Académie sous la licence que VOUS choisissez â c'est la seule chose du registre que l'auteur décide, parce que c'est son Åuvre.", 2650 `<button onclick="window.academieDepotForm(true)" class="w-full text-left rounded-2xl p-4 transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px dashed var(--border)"> 2651 <div class="flex items-center gap-3"> 2652 <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style="background:var(--bg-input);color:var(--t-secondary)">${_icon('plus', 'w-4 h-4')}</span> 2653 <div class="min-w-0 flex-1"> 2654 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Déposer mon cours</p> 2655 <p class="text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-dim)">Titre, contenu, licence. Vous pouvez le retirer à tout moment â personne d'autre ne le peut.</p> 2656 </div> 2657 ${_icon('chevronRight', 'w-4 h-4 shrink-0')} 2658 </div> 2659 </button>`) 2660 } 2661 const champ = (id, lab, ph, type) => `<label class="flex flex-col gap-1"> 2662 <span class="text-[max(0.6875rem,11px)] font-semibold" style="color:var(--t-dim)">${escHtml(lab)}</span> 2663 <input id="${id}" type="${type || 'text'}" placeholder="${escHtml(ph || '')}" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border)" /> 2664 </label>` 2665 return _academieRangee('Déposer un cours', "La licence que vous choisissez dit aux autres ce qu'ils pourront en faire â la traduire, l'adapter, la republier. Elle ne se change pas dans leur dos.", 2666 `<div class="rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2667 <div class="grid gap-2.5 mb-2.5" style="grid-template-columns:repeat(auto-fill,minmax(200px,1fr))"> 2668 ${champ('depot-titre', 'Titre du cours', 'Ce quâil apprend')} 2669 ${champ('depot-auteur', 'Votre nom dâauteur', 'Affiché dans le crédit')} 2670 ${champ('depot-langue', 'Langue', 'fr')} 2671 ${champ('depot-minutes', 'Durée en minutes', '30', 'number')} 2672 </div> 2673 ${champ('depot-quoi', 'En une phrase', 'Ce que la personne saura faire à la fin')} 2674 <label class="flex flex-col gap-1 mt-2.5"> 2675 <span class="text-[max(0.6875rem,11px)] font-semibold" style="color:var(--t-dim)">Le cours</span> 2676 <textarea id="depot-contenu" rows="6" placeholder="Le contenu de votre cours." class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border);resize:vertical"></textarea> 2677 </label> 2678 <label class="flex flex-col gap-1 mt-2.5"> 2679 <span class="text-[max(0.6875rem,11px)] font-semibold" style="color:var(--t-dim)">Sous quelle licence le donnez-vous ?</span> 2680 <select id="depot-licence" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border)"> 2681 <option value="">â choisissez â</option> 2682 ${licences.map(l => `<option value="${escHtml(l.id)}">${escHtml(l.nom)}</option>`).join('')} 2683 </select> 2684 </label> 2685 <div class="flex gap-2 mt-3"> 2686 <button onclick="window.academieDeposer()" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Déposer</button> 2687 <button onclick="window.academieDepotForm(false)" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">Annuler</button> 2688 </div> 2689 </div>`) 2690} 2691 2692/* ââ VUE 5 : LA FICHE D'UNE RESSOURCE âââââââââââââââââââââââââââââââââââââ */ 2693 2694/* L'ATTRIBUTION N'EST PAS DE LA PLOMBERIE â ELLE RESTE. 2695 * 2696 * Le CATALOGUE DES FOURNISSEURS ne regarde pas le client : savoir que CREA 2697 * parle à telle instance, que telle autre est « à configurer », c'est une 2698 * affaire interne. Mais le CRÃDIT d'une Åuvre, lui, est dû : une ressource en 2699 * CC BY doit dire d'où elle vient et sous quelle licence, sans quoi la 2700 * republier est une violation. La fiche porte donc la source, la licence, 2701 * l'auteur et le lien vers l'original â et l'écran d'administration porte le 2702 * reste. 2703 * 2704 * Ce que la fiche NE fait pas : traduire, adapter, recomposer. Les droits sont 2705 * affichés parce qu'ils se lisent AVANT d'agir ; le moteur qui agira n'existe 2706 * pas encore, et une fiche qui promettrait « traduire » mentirait. 2707 */ 2708function _academieFicheImportee(r) { 2709 const d = r.droits || {} 2710 const permis = [d.traduire && 'traduire', d.adapter && 'adapter', d.redistribuer && 'redistribuer', d.commercial && 'usage commercial'].filter(
2710Boolean) 2711 /* LE NOM VIENT DES CRÃDITS, PAS DE LA LISTE DES SOURCES. Cette liste n'est 2712 plus lue en vue client â la fiche aurait affiché « moodle » au lieu de 2713 « Moodle », et le crédit dû n'aurait pas été rendu. */ 2714 const nomSource = (STATE.academieCredits || {})[r.sourceId] || r.sourceId 2715 const ligne = (t, v) => v ? `<div class="flex gap-2 text-[max(0.8125rem,13px)]"><span style="color:var(--t-faint);min-width:92px">${escHtml(t)}</span><span style="color:var(--t-secondary)">${escHtml(v)}</span></div>` : '' 2716 return `<div class="rounded-2xl p-5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2717 <p class="text-[max(0.75rem,12px)] mb-3" style="color:var(--t-faint)">${escHtml(r.typeLabel)}${r.minutes ? ' · ' + r.minutes + ' min' : ''}</p> 2718 ${r.quoi ? `<p class="text-[max(0.875rem,14px)] mb-4" style="color:var(--t-dim)">${escHtml(r.quoi)}</p>` : ''} 2719 <div class="space-y-1.5 mb-4"> 2720 ${ligne('Source', nomSource)} 2721 ${ligne('Auteur', r.auteur)} 2722 ${ligne('Langue', r.langue ? String(r.langue).toUpperCase() : '')} 2723 ${ligne('Licence', r.licence)} 2724 ${ligne('Vous pouvez', permis.length ? permis.join(', ') : 'lire et citer')} 2725 ${d.attribution ? ligne('Obligation', 'citer la source et lâauteur') : ''} 2726 </div> 2727 ${_academieBlocTransformer(r)} 2728 <div class="flex flex-wrap gap-2 mt-4"> 2729 <button onclick="window.academieOuvrir('ecole')" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ouvrir lâÃcole</button> 2730 ${r.url ? `<a href="${escHtml(r.url)}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary);text-decoration:none">Voir lâoriginal ${_icon('externe', 'w-3.5 h-3.5')}</a>` : ''} 2731 </div> 2732 </div>` 2733} 2734 2735function _academieFiche(cle) { 2736 const [type, id] = String(cle || '').split(':') 2737 /* UNE RESSOURCE IMPORTÃE A SA FICHE, ET C'EST LE PREMIER TEST. 2738 Elle porte le type « cours » sans être dans aucune table écrite : la 2739 recherche par table échouait et rendait l'accueil. Cliquer un cours 2740 importé ramenait donc en arrière â le MÃME défaut, muet, que la porte des 2741 matières. C'est aussi le SEUL « cours » qui reste : les deux cours en 2742 vedette écrits à la main sont partis avec ce qu'ils doublaient. */ 2743 const imp = _academieImportees().find(r => r.type === type && r.id === id) 2744 if (imp) return _academieFicheImportee(imp) 2745 /* UN TUTORIEL N'A PAS DE FICHE : IL A SON ÃCRAN. Il en avait une â un pavé 2746 qui recopiait les étapes en liste morte, sans progression et sans case à 2747 cocher, pendant que l'écran du tutoriel savait déjà tout faire. Deux vues 2748 du même contenu, dont une qui ne retenait rien de ce qu'on y faisait. 2749 La clé du catalogue EST celle de l'écran (`parcours/tutoriel`) : on la lui 2750 passe telle quelle. */ 2751 if (type === 'tutoriels') return _tutoTutoriel(id) 2752 /* LE TYPE « cours » N'EXISTE PLUS EN PROPRE. Il ne reste que les cours 2753 IMPORTÃS, traités plus haut par leur fiche : celle-ci porte le crédit dû à 2754 l'Åuvre, ce que la fiche écrite à la main ne faisait pas. */ 2755 const c = ACADEMIE_CAS.find(x => x.id === id); if (!c) return _academieTutoriels() 2756 const role = ACADEMIE_ROLES.find(r => r.id === c.role) 2757 return `<div class="rounded-2xl p-5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2758 <p class="text-[max(0.75rem,12px)] mb-3" style="color:var(--t-faint)">Cas d'usage · ${escHtml(role ? role.label : '')} · ${c.minutes} min</p> 2759 <p class="text-[max(0.875rem,14px)] mb-4" style="color:var(--t-dim)">${escHtml(c.quoi)}</p> 2760 <ol class="space-y-2.5 mb-5">${c.etapes.map((e, i) => `<li class="flex gap-3"> 2761 <span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[max(0.625rem,10px)] font-bold" style="background:var(--accent);color:#fff">${i + 1}</span>
2762 <span class="text-[max(0.8125rem,13px)] leading-snug" style="color:var(--t-secondary)">${escHtml(e)}</span> 2763 </li>`).join('')}</ol> 2764 <button onclick="window.academieOuvrir('${c.ou}')" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ouvrir ${escHtml(_academieNomDuLieu(c.ou))}</button> 2765 </div>` 2766} 2767 2768/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2769 * LES CARTES ILLUSTRÃES DE L'ACADÃMIE â UN SEUL JEU, DEUX SECTIONS 2770 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2771 * 2772 * Elles s'appelaient `_ecole*` et vivaient dans le bloc de l'Ãcole, parce que 2773 * l'Ãcole était seule à en avoir. Les tutoriels reçoivent la même colonne et 2774 * les mêmes cartes : les laisser sous un nom d'Ãcole aurait donné, au premier 2775 * ajustement, DEUX jeux de cartes qui se ressemblent â celui qu'on retouche et 2776 * celui qu'on oublie. C'est le canal parallèle habituel, en version visuelle. 2777 */ 2778/* LA CARTE ILLUSTRÃE â le geste des espaces, pas un second. 2779 `/api/image` sert déjà les cartes de Découvrir, Santé et Brevets. Le dégradé 2780 n'est pas un ornement : c'est ce qu'on voit pendant que la photo arrive, et 2781 ce qui reste si elle n'arrive jamais. Sans lui, un rectangle blanc. */ 2782/* LA PHOTO EST UN CARRÃ, ET ELLE EST DEMANDÃE CARRÃE. 2783 Elle était large et basse (520 à 320), posée EN HAUT d'une carte, le titre 2784 dessous. Signalé, et juste : « ça ne permet pas de voir bien les photos ». Un 2785 bandeau de 116 px de haut recadre `object-cover` sur une bande étroite au 2786 milieu â on perd le sujet, et il ne reste qu'une texture. 2787 Un carré demandé carré n'est pas recadré du tout : on voit ce qui a été 2788 généré. Et la même image sert de 96 à 160 px sans repasser au fournisseur, 2789 puisque la clé du cache est le prompt et les DIMENSIONS DEMANDÃES. */ 2790function _academieVignetteCarree(o, cote, badge) { 2791 const src = '/api/image?prompt=' + encodeURIComponent(o.img) + '&width=640&height=640&seed=' + (o.seed || 1) 2792 return `<span class="block relative overflow-hidden shrink-0" style="width:${cote}px;height:${cote}px;border-radius:12px;background:linear-gradient(135deg,${o.c1},${o.c2})"> 2793 <img src="${escHtml(src)}" loading="lazy" alt="" class="absolute inset-0 w-full h-full object-cover" onerror="this.style.display='none'"> 2794 ${badge ? `<span class="absolute bottom-1.5 left-1.5 right-1.5 rounded-md px-1.5 py-0.5 text-[max(0.625rem,10px)] font-bold text-center truncate" style="background:rgba(255,255,255,.94);color:#111">${escHtml(badge)}</span>` : ''} 2795 </span>` 2796} 2797 2798/* LA CARTE COUCHÃE â LA PHOTO à GAUCHE, TOUT LE RESTE à CÃTÃ. 2799 « L'idéal, ça aurait été la photo dans un carré et son titre à côté. Comme 2800 ça, ça laisse la pleine ampleur à la photo, et tout ce qu'il y a à écrire à 2801 côté. » C'est exactement ce que fait cette carte, et c'est aussi ce qui 2802 remplit la largeur : une carte debout ne peut pas dépasser la largeur de sa 2803 photo, donc trois colonnes étroites et beaucoup de blanc de chaque côté. 2804 Couchée, elle prend 480 px et le texte respire. */ 2805function _academieCarteCouchee({ clic, o, cote, titre, quoi, meta, part, badge, bordure }) { 2806 return `<button onclick="${clic}" class="text-left flex items-center gap-3.5 p-3 rounded-2xl transition-colors hover:bg-black/[.035]" style="background:var(--bg-input);border:1px solid ${bordure || 'var(--border)'}"> 2807 ${_academieVignetteCarree(o, cote || 104, badge)} 2808 <span class="flex-1 min-w-0 flex flex-col gap-1"> 2809 <span class="block text-[max(0.9375rem,15px)] font-bold leading-snug truncate" style="color:var(--t-primary)">${escHtml(titre)}</span> 2810 ${quoi ? `<span class="block text-[max(0.78125rem,12.5px)] leading-snug" style="color:var(--t-dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden">${escHtml(quoi)}</span>` : ''} 2811 ${meta ? `<span class="block text-[max(0.6875rem,11px)] mt-0.5" style="color:var(--t-faint)">${escHtml(meta)}</span>` : ''} 2812 ${part === undefined ? '' : `<span class="block mt-1">${_academieBarre(part)}</span>`} 2813 </span> 2814 </button>` 2815} 2816 2817/* LA GRILLE DE L'ÃCOLE. `minmax(420px, 1fr)` et pas trois colonnes figées : 2818 sur la largeur du plan de travail ça donne deux à trois cartes couchées 2819 lisibles, et une seule colonne sur un téléphone â sans écrire un seul point 2820 de rupture. */ 2821const ACADEMIE_GRILLE = 'display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(420px,1fr))' 2822 2823 2824function _academieBarre(part, epaisseur) { 2825 return `<span class="block rounded-full overflow-hidden" style="height:${epaisseur || 4}px;background:var(--bg-tertiary)"> 2826 <span class="block h-full rounded-full" style="width:${Math.max(0, Math.min(100, part))}%;background:var(--accent)"></span> 2827 </span>` 2828} 2829 2830/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2831 * LES TUTORIELS â QUATRE ÃCRANS, LA MÃME COLONNE QUE L'ÃCOLE 2832 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2833 * 2834 * « La partie école commence à être propre toute la page, mais la partie 2835 * tutoriel elle n'est pas encore à la même portée que la partie école. » 2836 * 2837 * Avant : quatre fiches à plat, sans progression, sans image, sans ordre â et 2838 * dessous, empilés, un dépôt, une administration, une newsletter et un bilan. 2839 * La page se lisait comme un fond de tiroir. 2840 * 2841 * Maintenant, dans l'ordre où on les traverse : 2842 * 2843 * ACCUEIL les séances EN HAUT, la reprise, les huit domaines 2844 * DOMAINE ses parcours, chacun avec sa progression 2845 * PARCOURS ses tutoriels, dans l'ordre 2846 * TUTORIEL ses étapes, qu'on coche â et le lieu où les faire 2847 * 2848 * ââ POURQUOI LES ÃTAPES SE COCHENT ICI, ET N'ONT PAS D'ÃCRAN âââââââââââââ 2849 * 2850 * Une leçon de l'Ãcole est une page : on l'ouvre, on la lit, elle dure. Une 2851 * étape de tutoriel est une ligne â « le trombone joint un fichier ». Lui 2852 * donner un écran serait un clic pour une phrase. On la coche là où on la lit, 2853 * et la progression est du même grain que le geste. 2854 * 2855 * ââ LES SÃANCES SONT EN HAUT, ET C'EST UNE CORRECTION ââââââââââââââââââââ 2856 * 2857 * « Séance en direct, ça, c'est à mettre en tête, ce n'est pas à mettre en 2858 * bas. » Une séance a une DATE : passé l'heure, la montrer ne sert plus à 2859 * rien. Un bloc daté qu'il faut faire défiler pour voir est un bloc qu'on 2860 * découvre le lendemain. 2861 */ 2862const TUTO_VUES = ['accueil', 'domaine', 'parcours', 'tutoriel'] 2863 2864window.tutoVue = function (vue, cle) { 2865 STATE.tutoVue = TUTO_VUES.includes(vue) ? vue : 'accueil' 2866 STATE.tutoCle = cle || null 2867 save(); render() 2868} 2869 2870/* COCHER UNE ÃTAPE NE FAIT PAS QUITTER LE TUTORIEL. C'est la différence avec 2871 la leçon, qui enchaîne : ici les étapes se lisent ensemble, et sauter à la 2872 suivante ferait perdre celle qu'on vient de faire des yeux. */ 2873window.tutoEtape = function (cle) { 2874 window.tutoMarquer(cle) 2875 save(); render() 2876} 2877 2878/* LE FIL D'ARIANE â il dit où l'on est, et chaque cran ramène. Le même que
2879 celui de l'Ãcole, et pour la même raison : sans lui, on descend trois étages 2880 et le seul retour est le bouton du navigateur. */ 2881function _tutoFil() { 2882 const vue = TUTO_VUES.includes(STATE.tutoVue) ? STATE.tutoVue : 'accueil' 2883 if (vue === 'accueil') return '' 2884 const cle = String(STATE.tutoCle || '') 2885 const [pid, tid] = cle.split('/') 2886 const p = vue === 'domaine' ? null : window.tutoParcours(pid) 2887 const d = vue === 'domaine' ? window.tutoDomaine(cle) : (p ? window.tutoDomaine(p.d) : null) 2888 const t = (p && tid) ? window.tutoUn(pid, tid) : null 2889 const crans = [{ l: 'Tutoriels', v: 'accueil', c: null }] 2890 if (d) crans.push({ l: d.nom, v: 'domaine', c: d.id }) 2891 if (p) crans.push({ l: p.nom, v: 'parcours', c: p.id }) 2892 if (t) crans.push({ l: t.titre, v: 'tutoriel', c: pid + '/' + tid }) 2893 return `<div class="flex flex-wrap items-center gap-1.5 mb-4 text-[max(0.78125rem,12.5px)]">${crans.map((x, i) => ` 2894 <button onclick="window.tutoVue('${x.v}'${x.c ? `,'${escHtml(x.c)}'` : ''})" style="color:${i === crans.length - 1 ? 'var(--t-secondary)' : 'var(--t-faint)'};background:none;border:none;font-weight:${i === crans.length - 1 ? '600' : '500'}">${escHtml(x.l)}</button> 2895 ${i < crans.length - 1 ? `<span style="color:var(--t-faint)">âº</span>` : ''}`).join('')}</div>` 2896} 2897 2898/* ââ ÃCRAN 1 : L'ACCUEIL DES TUTORIELS ââââââââââââââââââââââââââââââââââââ */ 2899function _tutoAccueil() { 2900 const reprise = window.tutoReprendre() 2901 /* « REPRENDRE » NE PROPOSE JAMAIS UN TUTORIEL JAMAIS OUVERT â même règle 2902 qu'à l'Ãcole, et pour la même raison : reprendre ce qu'on n'a pas commencé 2903 est le mensonge qui décrédibilise un écran d'accueil. */ 2904 const bandeau = reprise ? _academieRangee('Reprendre', 'là où vou
2904s vous êtes arrêté', 2905 `<button onclick="window.tutoVue('tutoriel','${escHtml(reprise.parcours.id + '/' + reprise.tutoriel.id)}')" class="w-full text-left flex items-stretch rounded-2xl overflow-hidden transition-colors hover:bg-black/5" style="background:var(--bg-input);border:1px solid var(--border)"> 2906 <span class="shrink-0 p-3">${_academieVignetteCarree(reprise.parcours, 104, reprise.tutoriel.minutes + ' min')}</span> 2907 <span class="flex-1 min-w-0 py-4 pr-2 flex flex-col gap-2.5 justify-center"> 2908 <span class="block text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${escHtml(reprise.domaine.nom)} ⺠${escHtml(reprise.parcours.nom)}</span> 2909 <span class="block text-[max(1.0625rem,17px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(reprise.tutoriel.titre)}</span> 2910 <span class="flex items-center gap-3"> 2911 <span class="flex-1">${_academieBarre(reprise.progression.part, 5)}</span> 2912 <span class="shrink-0 text-[max(0.75rem,12px)] font-semibold" style="color:var(--t-dim)">${reprise.progression.faites} / ${reprise.progression.total} étapes</span> 2913 </span> 2914 </span> 2915 <span class="shrink-0 flex items-center pr-5 pl-2"> 2916 <span class="inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-[max(0.875rem,14px)] font-semibold" style="background:var(--accent);color:#fff">Continuer ${_icon('chevronRight', 'w-4 h-4')}</span> 2917 </span> 2918 </button>`) : '' 2919 2920 const domaines = `<div style="${ACADEMIE_GRILLE}">${TUTO_DOMAINES.map(d => { 2921 const p = window.tutoProgressionDomaine(d.id) 2922 const commence = p.faites > 0 && p.faites < p.total 2923 return _academieCarteCouchee({ 2924 clic: `window.tutoVue('domaine','${escHtml(d.id)}')`, o: d, cote: 112, 2925 titre: d.nom, quoi: d.quoi, 2926 meta: p.parcours + ' parcours · ' + p.total + ' étapes' + (p.faites ? ' · ' + p.faites + ' faites' : ''), 2927 part: p.faites ? p.part : undefined, 2928 badge: commence ? 'EN COURS' : p.total && p.faites >= p.total ? 'TERMINÃ' : '', 2929 bordure: commence ? 'var(--accent)' : undefined, 2930 }) 2931 }).join('')}</div>` 2932 2933 /* LES DEUX ANCIENNES RANGÃES QUI SERVAIENT ENCORE. « Par rôle » et « par 2934 lieu » ne doublaient rien : l'une range les prompts par métier, l'autre 2935 dit ce qui se pratique à chaque endroit. Elles descendent sous la colonne, 2936 là où l'on va quand on ne cherche pas un tutoriel précis. */ 2937 const roles = `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">${ACADEMIE_ROLES.map(r => ` 2938 <button onclick="window.academieVue('role','${r.id}')" class="flex items-center gap-3 rounded-2xl p-3.5 text-left transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2939 <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style="background:var(--bg-input);color:var(--t-secondary)">${_icon(r.icone, 'w-4 h-4')}</span> 2940 <span class="text-[max(0.875rem,14px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(r.label)}</span> 2941 </button>`).join('')}</div>` 2942 const lieux = `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">${_academieLieux().map(id => { 2943 const n = _academieCatalogue().filter(r => r.ou === id).length 2944 return `<button onclick="window.academieVue('lieu','${id}')" class="rounded-2xl p-3.5 text-left transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 2945 <span class="block text-[max(0.875rem,14px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(_academieTitreDuLieu(id))}</span> 2946 <span class="block text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-faint)">${n} ressource${n > 1 ? 's' : ''}</span> 2947 </button>` 2948 }).join('')}</div>` 2949 2950 const v = window.tutoVerifier() 2951 /* LES SÃANCES PASSENT DEVANT TOUT â voir l'en-tête de ce bloc. */ 2952 return _academieRangee('Séances en direct', 'Ce qui se passe en ce moment, et les rediffusions.', _academieWebinaires()) 2953 + bandeau 2954 + _academieRangee('Apprendre CREA', 2955 `${v.domaines} domaines · ${v.parcours} parcours · ${v.tutoriels} tutoriels · ${v.etapes} étapes`, domaines) 2956 + _academieRangee('Parcourir par rôle', "Prompts et cas d'usage adaptés au travail que vous faites.", roles) 2957 + _academieRangee('Parcourir par lieu', 'Ce qui se pratique dans chaque endroit de CREA.', lieux, _academieLienTout('ressources')) 2958} 2959 2960/* ââ ÃCRAN 2 : UN DOMAINE âââââââââââââââââââââââââââââââââââââââââââââââââ */ 2961function _tutoDomaineEcran(did) { 2962 const d = window.tutoDomaine(did) 2963 if (!d) return _tutoAccueil() 2964 const pd = window.tutoProgressionDomaine(did) 2965 const parcours = `<div style="${ACADEMIE_GRILLE}">${window.tutoParcoursDuDomaine(did).map(p => { 2966 const pp = window.tutoProgressionParcours(p.id) 2967 const commence = pp.faites > 0 && pp.faites < pp.total 2968 return _academieCarteCouchee({ 2969 clic: `window.tutoVue('parcours','${escHtml(p.id)}')`, o: p, cote: 112, 2970 titre: p.nom, quoi: p.quoi, 2971 meta: pp.tutos + (pp.tutos > 1 ? ' tutoriels · ' : ' tutoriel · ') + p.minutes + ' min · ' + pp.total + ' étapes', 2972 part: pp.faites ? pp.part : undefined, 2973 badge: commence ? 'EN COURS' : pp.total && pp.faites >= pp.total ? 'TERMINÃ' : '', 2974 bordure: commence ? 'var(--accent)' : undefined, 2975 }) 2976 }).join('')}</div>` 2977 return _academieRangee(d.nom, `${d.quoi} â ${pd.parcours} parcours, ${pd.total} étapes, ${pd.part} % parcouru`, parcours) 2978} 2979 2980/* ââ ÃCRAN 3 : UN PARCOURS ââââââââââââââââââââââââââââââââââââââââââââââââ */ 2981function _tutoParcoursEcran(pid) { 2982 const p = window.tutoParcours(pid) 2983 if (!p) return _tutoAccueil() 2984 const pp = window.tutoProgressionParcours(pid) 2985 const lignes = p.tutos.map((t, i) => { 2986 const pt = window.tutoProgressionTuto(pid, t.id) 2987 const fini = pt.total && pt.faites >= pt.total 2988 return `<button onclick="window.tutoVue('tutoriel','${escHtml(pid + '/' + t.id)}')" class="w-full text-left flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-black/[.035]" style="background:none;border:none;border-top:${i ? '1px solid var(--border-dim)' : 'none'}">
2989 <span class="shrink-0 inline-flex items-center justify-center rounded-full text-[max(0.75rem,12px)] font-bold" style="width:26px;height:26px;background:${fini ? 'var(--accent)' : 'var(--bg-tertiary)'};color:${fini ? '#fff' : 'var(--t-dim)'}">${fini ? 'â' : i + 1}</span> 2990 <span class="flex-1 min-w-0"> 2991 <span class="block text-[max(0.90625rem,14.5px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(t.titre)}</span> 2992 <span class="block text-[max(0.78125rem,12.5px)] leading-snug mt-0.5" style="color:var(--t-dim)">${escHtml(t.objectif)}</span> 2993 </span> 2994 <span class="shrink-0 text-[max(0.71875rem,11.5px)] font-semibold" style="color:var(--t-faint)">${pt.faites}/${pt.total} · ${t.minutes} min</span> 2995 ${_icon('chevronRight', 'w-4 h-4 shrink-0')} 2996 </button>` 2997 }).join('') 2998 const entete = `<div class="flex items-center gap-4 p-4 rounded-2xl mb-4" style="background:var(--bg-input);border:1px solid var(--border)"> 2999 ${_academieVignetteCarree(p, 128)} 3000 <div class="flex-1 min-w-0">
3001 <p class="text-[max(1.25rem,20px)] font-bold leading-tight" style="color:var(--t-primary)">${escHtml(p.nom)}</p> 3002 <p class="text-[max(0.875rem,14px)] mt-1" style="color:var(--t-dim)">${escHtml(p.quoi)}</p> 3003 <p class="text-[max(0.75rem,12px)] mt-2" style="color:var(--t-faint)">${pp.tutos} ${pp.tutos > 1 ? 'tutoriels' : 'tutoriel'} · ${p.minutes} min · ${pp.faites} / ${pp.total} étapes faites</p> 3004 <span class="block mt-2">${_academieBarre(pp.part, 5)}</span> 3005 </div> 3006 </div>` 3007 return entete + `<div class="rounded-2xl overflow-hidden" style="background:var(--bg-secondary);border:1px solid var(--border)">${lignes}</div>` 3008} 3009 3010/* ââ ÃCRAN 4 : UN TUTORIEL ââââââââââââââââââââââââââââââââââââââââââââââââ */ 3011/* LE GESTE DES MODÃLES, PAS UN SECOND. `data-studio-template-card` + 3012 `data-template-exemple` ouvre le lieu, crée le projet de code s'il en manque 3013 un, et ÃCRIT la demande dans la barre sans l'envoyer. C'est le geste qui 3014 existe déjà (voir bindEvents) : un gestionnaire de plus ici aurait donné 3015 deux comportements pour un seul clic. */ 3016function _tutoTutoriel(cle) { 3017 const [pid, tid] = String(cle || '').split('/') 3018 const p = window.tutoParcours(pid) 3019 const t = p && window.tutoUn(pid, tid) 3020 if (!t) return _tutoAccueil() 3021 const pt = window.tutoProgressionTuto(pid, tid) 3022 const suivant = p.tutos[p.tutos.findIndex(x => x.id === tid) + 1] 3023 3024 const jauge = `<div class="flex items-center gap-4 px-5 py-3.5 mb-5 rounded-2xl" style="background:var(--bg-input);border:1px solid var(--border)"> 3025 <button onclick="window.tutoVue('parcours','${escHtml(pid)}')" class="shrink-0 inline-flex items-center gap-1.5 text-[max(0.75rem,12px)] font-medium" style="color:var(--t-dim);background:none;border:none">${_icon('chevronLeft', 'w-3.5 h-3.5')}${escHtml(p.nom)}</button> 3026 <span class="flex-1">${_academieBarre(pt.part, 5)}</span> 3027 <span class="shrink-0 text-[max(0.75rem,12px)] font-semibold" style="color:var(--t-dim)">${pt.faites} / ${pt.total} étapes</span> 3028 </div>` 3029 3030 /* CHAQUE ÃTAPE EST UN INTERRUPTEUR, ET LE DIT. Une puce numérotée qui ne se 3031 coche pas se lit comme une liste ; une case qui se coche se lit comme un 3032 geste à faire. C'est toute la différence entre lire un tutoriel et le 3033 suivre. */ 3034 const etapes = `<ol class="rounded-2xl overflow-hidden mb-5" style="background:var(--bg-input);border:1px solid var(--border)">${t.etapes.map((e, i) => { 3035 const fait = window.tutoEtapeFaite(e.cle) 3036 return `<li><button onclick="window.tutoEtape('${escHtml(e.cle)}')" class="w-full text-left flex items-start gap-3.5 px-5 py-3.5 transition-colors hover:bg-black/[.035]" style="background:none;border:none;border-top:${i ? '1px solid var(--border-dim)' : 'none'}"> 3037 <span class="shrink-0 inline-flex items-center justify-center mt-0.5" style="width:22px;height:22px;border-radius:6px;background:${fait ? 'var(--accent)' : 'var(--bg-secondary)'};border:1px solid ${fait ? 'var(--accent)' : 'var(--border)'};color:#fff">${fait ? _icon('check', 'w-3.5 h-3.5') : ''}</span> 3038 <span class="flex-1 text-[max(0.90625rem,14.5px)] leading-relaxed" style="color:${fait ? 'var(--t-faint)' : 'var(--t-secondary)'};text-decoration:${fait ? 'line-through' : 'none'}">${escHtml(e.texte)}</span> 3039 </button></li>` 3040 }).join('')}</ol>` 3041 3042 const essai = `<div class="rounded-2xl p-4 mb-5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 3043 <p class="text-[max(0.8125rem,13px)] font-semibold mb-1" style="color:var(--t-secondary)">Essayer tout de suite</p> 3044 <p class="text-[max(0.84375rem,13.5px)] mb-3" style="color:var(--t-dim)">« ${escHtml(t.exemple)} »</p> 3045 <div class="flex flex-wrap gap-2"> 3046 <button data-studio-template-card="${escHtml(t.ouvre)}" data-template-exemple="${escHtml(t.exemple)}" class="rounded-full px-4 py-2 text-[max(0.8125rem,13px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ouvrir avec cette demande</button>
3047 <button onclick="window.academieOuvrir('${escHtml(t.ouvre)}')" class="rounded-full px-4 py-2 text-[max(0.8125rem,13px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${escHtml(t.ouvreLabel)}</button> 3048 </div> 3049 </div>` 3050 3051 const nav = `<div class="flex items-center justify-between gap-4"> 3052 <button onclick="window.tutoVue('parcours','${escHtml(pid)}')" class="inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-[max(0.84375rem,13.5px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${_icon('chevronLeft', 'w-4 h-4')}Le parcours</button> 3053 ${suivant ? `<button onclick="window.tutoVue('tutoriel','${escHtml(pid + '/' + suivant.id)}')" class="inline-flex items-center gap-2 rounded-full px-6 py-3 text-[max(0.90625rem,14.5px)] font-semibold" style="background:var(--accent);color:#fff;border:none">${escHtml(suivant.titre)} ${_icon('chevronRight', 'w-4 h-4')}</button>` : '<span></span>'} 3054 </div>` 3055 3056 return jauge + `<div class="flex items-center gap-2 mb-2"> 3057 <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[max(0.71875rem,11.5px)] font-bold" style="background:var(--accent-bg);color:var(--accent)">TUTORIEL</span> 3058 <span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${t.minutes} min · ${escHtml(p.nom)}</span> 3059 </div> 3060 <p class="text-[max(1.6875rem,27px)] font-bold leading-tight mb-1.5" style="color:var(--t-primary)">${escHtml(t.titre)}</p> 3061 <p class="text-[max(0.9375rem,15px)] mb-5" style="color:var(--t-dim)">${escHtml(t.objectif)}</p>` 3062 + etapes + essai + nav 3063} 3064 3065function _academieTutoriels() { 3066 const vue = TUTO_VUES.includes(STATE.tutoVue) ? STATE.tutoVue : 'accueil' 3067 const cle = STATE.tutoCle 3068 const corps = vue === 'domaine' ? _tutoDomaineEcran(cle) 3069 : vue === 'parcours' ? _tutoParcoursEcran(cle) 3070 : vue === 'tutoriel' ? _tutoTutoriel(cle) 3071 : _tutoAccueil() 3072 return _tutoFil() + corps 3073} 3074 3075/* ââ VUE 6 : L'ÃDUCATION ââââââââââââââââââââââââââââââââââââââââââââââââââ */ 3076/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3077 * L'ÃCOLE â CINQ ÃCRANS, ET AUCUN NE TOMBE SUR UN CHAT 3078 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3079 * 3080 * Avant : une grille de quatorze matières, et cliquer l'une d'elles sautait 3081 * par-dessus tout le programme pour atterrir sur la séance du Studio 3082 * Ãducation â une barre de chat. Les cent quatorze sous-domaines de la table ne s'affichaient 3083 * jamais. « Apprendre une matière » voulait dire « poser une question ». 3084 * 3085 * Maintenant, dans l'ordre où on les traverse : 3086 * 3087 * ACCUEIL reprendre là où on s'est arrêté, puis les huit filières 3088 * FILIÃRE ses matières, chacune avec sa progression 3089 * MATIÃRE ses cours, dans l'ordre, avec ce qui est fait 3090 * COURS son plan de leçons, et le contrôle verrouillé au bout 3091 * LEÃON la leçon elle-même â et le professeur en SECOND RÃLE 3092 * 3093 * LE PROFESSEUR PARTICULIER N'EST PLUS UNE DESTINATION. Il est une bande DANS 3094 * la leçon : « explique autrement », « interroge-moi ». C'est tout le 3095 * correctif â on arrive sur un contenu, pas sur une barre de saisie. 3096 */ 3097const ECOLE_VUES = ['accueil', 'filiere', 'matiere', 'cours', 'lecon'] 3098 3099window.ecoleVue = function (vue, cle) { 3100 STATE.ecoleVue = ECOLE_VUES.includes(vue) ? vue : 'accueil' 3101 STATE.ecoleCle = cle || null 3102 save(); render() 3103} 3104 3105window.ecoleFaite = function (cle, suivante) { 3106 window.ecoleMarquer(cle, true) 3107 /* ON ENCHAÃNE. Marquer une leçon faite et rester dessus oblige à revenir au 3108 plan pour cliquer la suivante â trois gestes pour un. */ 3109 if (suivante) STATE.ecoleCle = suivante 3110 else STATE.ecoleVue = 'cours' 3111 save(); render() 3112} 3113 3114/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3115 * LES SÃANCES EN DIRECT ENTRENT DANS L'ÃCOLE 3116 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3117 * 3118 * « Séance en direct, ça, c'est à mettre en tête, ce n'est pas à mettre en 3119 * bas. Et d'ailleurs, il faut qu'ils interviennent aussi dans les écoles. » 3120 * 3121 * Elles n'existaient que dans une rangée des tutoriels. Or une séance porte 3122 * une MATIÃRE : c'est sur la page de cette matière qu'elle sert â au moment 3123 * où quelqu'un décide s'il apprend seul ou s'il attend jeudi. 3124 * 3125 * ââ POURQUOI LE RAPPROCHEMENT SE FAIT SUR LE NOM, ET CE QUE ÃA COÃTE âââââ 3126 * 3127 * Le champ « matière » du formulaire est du texte libre côté serveur : rien 3128 * ne garantit qu'il tombe sur une des cinquante-six. On compare donc des noms 3129 * NORMALISÃS â sans accents, sans casse, sans ponctuation â et le formulaire 3130 * PROPOSE désormais les matières réelles (voir sa `datalist`), pour que le 3131 * rapprochement soit la règle et non la chance. 3132 *
3133 * CE QUI NE CORRESPOND à RIEN N'EST PAS PERDU : la séance reste dans la rangée 3134 * de l'accueil. Une séance qu'on ne retrouve nulle part serait pire qu'une 3135 * séance mal rangée. 3136 */ 3137function _seanceNormaliser(x) { 3138 return String(x == null ? '' : x).normalize('NFD').replace(/[\u0300-\u036f]/g, '') 3139 .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() 3140} 3141 3142/* LES SÃANCES QUI VISENT CETTE MATIÃRE. `STATE.academieSeances` a trois états 3143 â pas encore demandé, refusé, la liste â et les confondre ferait passer une 3144 panne de réseau pour une matière sans séance. Ici on ne rend RIEN dans les 3145 deux premiers cas : sur la page d'une matière, une ligne « lecture⦠» qui 3146 ne finit jamais est un bruit, pas une information. */ 3147function _ecoleSeancesDeLaMatiere(mid) { 3148 const liste = STATE.academieSeances 3149 if (!Array.isArray(liste)) return [] 3150 const m = window.ecoleMatiere(mid) 3151 if (!m) return [] 3152 const cible = _seanceNormaliser(m.nom) 3153 return liste.filter(w => { 3154 const q = _seanceNormaliser(w.matiere) 3155 return q && (q === cible || q === _seanceNormaliser(m.id)) 3156 }) 3157} 3158 3159/* LES SÃANCES à VENIR, TOUTES MATIÃRES. Ce qui est PASSà ne monte pas en tête 3160 d'accueil : une rediffusion n'a pas d'heure, elle attendra la page de sa 3161 matière. En tête, on ne met que ce qui a une échéance. */ 3162function _ecoleSeancesAVenir() { 3163 const liste = STATE.academieSeances 3164 if (!Array.isArray(liste)) return [] 3165 return liste.filter(w => w.etat !== 'rediffusion') 3166} 3167 3168function _ecoleRangeeDeSeances(seances, titre, sousTitre) { 3169 if (!seances.length) return '' 3170 return _academieRangee(titre, sousTitre, 3171 `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">${seances.map(_academieCarteWebinaire).join('')}</div>`) 3172} 3173 3174/* ââ ÃCRAN 1 : L'ACCUEIL DE L'ÃCOLE âââââââââââââââââââââââââââââââââââââââ */ 3175function _ecoleAccueil() { 3176 const reprise = window.ecoleReprendre() 3177 /* « REPRENDRE » NE PROPOSE JAMAIS UN COURS JAMAIS OUVERT. Reprendre ce qu'on 3178 n'a pas commencé est un mensonge, et c'est celui qui décrédibilise un 3179 écran d'accueil pour de bon. Rien de commencé : on n'affiche pas la 3180 rangée, on invite à choisir. */ 3181 const bandeau = reprise ? _academieRangee('Reprendre', 'là où vou
3181s vous êtes arrêté', 3182 `<button onclick="window.ecoleVue('lecon','${escHtml(reprise.lecon.cle)}')" class="w-full text-left flex items-stretch rounded-2xl overflow-hidden transition-colors hover:bg-black/5" style="background:var(--bg-input);border:1px solid var(--border)"> 3183 <span class="shrink-0 p-3">${_academieVignetteCarree(reprise.matiere, 104, reprise.lecon.nature.label + ' · ' + reprise.lecon.m + ' min')}</span> 3184 <span class="flex-1 min-w-0 py-4 pr-2 flex flex-col gap-2.5 justify-center"> 3185 <span class="block text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${escHtml(reprise.matiere.nom)} ⺠${escHtml(reprise.cours.titre)} ⺠leçon ${reprise.lecon.rang + 1} sur ${reprise.cours.lecons.length}</span> 3186 <span class="block text-[max(1.0625rem,17px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(reprise.lecon.t)}</span> 3187 <span class="flex items-center gap-3"> 3188 <span class="flex-1">${_academieBarre(reprise.progression.part, 5)}</span> 3189 <span class="shrink-0 text-[max(0.75rem,12px)] font-semibold" style="color:var(--t-dim)">${reprise.progression.faites} / ${reprise.progression.total}</span> 3190 </span> 3191 </span> 3192 <span class="shrink-0 flex items-center pr-5 pl-2"> 3193 <span class="inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-[max(0.875rem,14px)] font-semibold" style="background:var(--accent);color:#fff">Continuer ${_icon('chevronRight', 'w-4 h-4')}</span> 3194 </span> 3195 </button>`) : '' 3196 3197 const filieres = `<div style="${ACADEMIE_GRILLE}">${ECOLE_FILIERES.map(f => { 3198 const p = window.ecoleProgressionFiliere(f.id) 3199 return _academieCarteCouchee({ 3200 clic: `window.ecoleVue('filiere','${escHtml(f.id)}')`, o: f, cote: 112, 3201 titre: f.nom, quoi: f.quoi, 3202 meta: p.matieres + ' matières · ' + p.total + ' leçons' + (p.faites ? ' · ' + p.faites + ' faites' : ''), 3203 part: p.faites ? p.part : undefined, 3204 bordure: p.faites > 0 && p.faites < p.total ? 'var(--accent)' : undefined, 3205 }) 3206 }).join('')}</div>` 3207 3208 const v = window.ecoleVerifier() 3209 /* LES SÃANCES PASSENT DEVANT, COMME AUX TUTORIELS â et pour la même raison : 3210 elles ont une heure. Passé l'heure, les montrer ne sert plus à rien. */ 3211 return _ecoleRangeeDeSeances(_ecoleSeancesAVenir(), 'Séances en direct', 3212 'Ce qui se passe en ce moment, et ce qui arrive â toutes matières.') 3213 + bandeau + _academieRangee('Les filières', 3214 `${v.filieres} filières · ${v.matieres} matières · ${v.cours} cours · ${v.lecons} leçons`, filieres) 3215} 3216 3217/* ââ ÃCRAN 2 : UNE FILIÃRE ââââââââââââââââââââââââââââââââââââââââââââââââ */ 3218function _ecoleFiliere(fid) { 3219 const f = window.ecoleFiliere(fid) 3220 if (!f) return _ecoleAccueil() 3221 const p = window.ecoleProgressionFiliere(fid) 3222 const matieres = `<div style="${ACADEMIE_GRILLE}">${window.ecoleMatieresDeLaFiliere(fid).map(m => { 3223 const pm = window.ecoleProgressionMatiere(m.id) 3224 const commencee = pm.faites > 0 && pm.faites < pm.total 3225 return _academieCarteCouchee({ 3226 clic: `window.ecoleVue('matiere','${escHtml(m.id)}')`, o: m, cote: 112, 3227 titre: m.nom, quoi: m.quoi, 3228 meta: pm.cours + ' cours · ' + pm.total + ' leçons · ' + (ECOLE_NIVEAUX[m.niv] || ''), 3229 part: pm.faites ? pm.part : undefined, 3230 badge: commencee ? 'EN COURS' : pm.faites >= pm.total && pm.total ? 'TERMINÃE' : '', 3231 bordure: commencee ? 'var(--accent)' : undefined, 3232 }) 3233 }).join('')}</div>` 3234 return _academieRangee(f.nom, `${f.quoi} â ${p.matieres} matières, ${p.total} leçons, ${p.part} % parcouru`, matieres) 3235} 3236 3237/* ââ ÃCRAN 3 : UNE MATIÃRE ââââââââââââââââââââââââââââââââââââââââââââââââ */ 3238function _ecoleMatiere(mid) { 3239 const m = window.ecoleMatiere(mid) 3240 if (!m) return _ecoleAccueil() 3241 const p = window.ecoleProgressionMatiere(mid) 3242 3243 /* UNE LIGNE DE COURS, sortie de la boucle des thèmes : elle était écrite une 3244 fois pour une liste plate, et un thème par-dessus l'aurait fait recopier. */ 3245 const ligne = (c, i) => { 3246 const pc = window.ecoleProgressionCours(mid, c.id) 3247 const fini = pc.faites >= pc.total 3248 const commence = pc.faites > 0 && !fini 3249 return `<button onclick="window.ecoleVue('cours','${escHtml(mid + '/' + c.id)}')" class="w-full text-left flex items-center gap-4 px-5 py-4" style="border-bottom:1px solid var(--border-dim);${commence ? 'background:var(--accent-bg)' : ''}">
3250 <span class="shrink-0 inline-flex items-center justify-center rounded-full" style="width:30px;height:30px;${fini ? 'background:var(--accent);color:#fff' : commence ? 'border:2px solid var(--accent);color:var(--accent)' : 'border:1px solid var(--border);color:var(--t-faint)'};font-size:12px;font-weight:700">${fini ? _icon('check', 'w-4 h-4') : (i + 1)}</span> 3251 <span class="flex-1 min-w-0 flex flex-col gap-1.5"> 3252 <span class="block text-[max(0.9375rem,15px)] font-${commence ? 'bold' : 'semibold'} leading-snug" style="color:${fini ? 'var(--t-faint)' : 'var(--t-primary)'}">${escHtml(c.titre)}</span> 3253 <span class="block text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${c.lecons.length} leçons · ${c.minutes} min · ${escHtml(c.niveauLisible)}</span> 3254 ${commence ? `<span class="flex items-center gap-2.5 mt-0.5"><span style="width:190px">${_academieBarre(pc.part)}</span><span class="text-[max(0.6875rem,11px)]" style="color:var(--t-dim)">${pc.faites} sur ${pc.total}</span></span>` : ''} 3255 </span> 3256 <span class="shrink-0 text-[max(0.75rem,12px)] font-semibold" style="color:${commence ? 'var(--accent)' : 'var(--t-dim)'}">${fini ? 'Revoir' : commence ? 'Reprendre' : 'Commencer'} ${_icon('chevronRight', 'w-3.5 h-3.5')}</span> 3257 </button>` 3258 } 3259 3260 /* LES THÃMES SONT LE SUJET DE CET ÃCRAN. C'est ce qui manquait : « il y a des 3261 domaines qui sont exposés, quand on les ouvre, on ne voit pas leur 3262 sous-domaine ». Chaque thème porte son nom, ce qu'il couvre, et sa propre 3263 progression â un bloc qu'on peut finir, au lieu d'une liste de vingt cours 3264 où l'on ne sait plus où l'on en est. 3265 ILS NE SONT PAS UN ÃCRAN DE PLUS : un étage de clic supplémentaire pour 3266 arriver au même cours n'aurait rien ajouté. */ 3267 const themes = window.ecoleThemes(mid).map((t, k) => { 3268 const total = t.cours.reduce((n, c) => n + c.lecons.length, 0) 3269 const faites = t.cours.reduce((n, c) => n + window.ecoleProgressionCours(mid, c.id).faites, 0) 3270 const part = total ? Math.round((faites / total) * 100) : 0 3271 return `<section class="mb-5"> 3272 <div class="flex items-end justify-between gap-3 mb-2 px-0.5"> 3273 <div class="min-w-0"> 3274 <p class="text-[max(1rem,16px)] font-bold leading-snug" style="color:var(--t-primary)"> 3275 <span class="mr-1.5" style="color:var(--t-faint)">${k + 1}.</span>${escHtml(t.nom)} 3276 </p> 3277 ${t.quoi ? `<p class="text-[max(0.78125rem,12.5px)] mt-0.5" style="color:var(--t-dim)">${escHtml(t.quoi)}</p>` : ''} 3278 </div> 3279 <div class="shrink-0 flex items-center gap-2.5" style="width:200px"> 3280 <span class="flex-1">${_academieBarre(part)}</span> 3281 <span class="text-[max(0.6875rem,11px)] font-semibold whitespace-nowrap" style="color:${faites ? 'var(--accent)' : 'var(--t-faint)'}">${faites} / ${total}</span> 3282 </div> 3283 </div> 3284 <div class="rounded-2xl overflow-hidden" style="background:var(--bg-input);border:1px solid var(--border)">${t.cours.map(ligne).join('')}</div> 3285 </section>` 3286 }).join('') 3287 3288 /* L'EN-TÃTE EST COUCHÃE ELLE AUSSI. La photo faisait un bandeau de 168 px 3289 par toute la largeur : `object-cover` y recadrait une bande étroite d'un 3290 carré, et on ne reconnaissait plus rien. Carrée à gauche, elle est 3291 entière ; le titre, la description et la progression tiennent à côté, 3292 dans la largeur qu'on n'utilisait pas. */ 3293 const entete = `<div class="flex items-center gap-5 p-4 rounded-2xl mb-6" style="background:var(--bg-input);border:1px solid var(--border)"> 3294 ${_academieVignetteCarree(m, 148, ECOLE_NIVEAUX[m.niv] || '')} 3295 <div class="flex-1 min-w-0"> 3296 <p class="text-[max(1.375rem,22px)] font-bold leading-tight" style="color:var(--t-primary)">${escHtml(m.nom)}</p> 3297 <p class="text-[max(0.875rem,14px)] mt-1.5 leading-snug" style="color:var(--t-dim)">${escHtml(m.quoi)}</p> 3298 <p class="text-[max(0.75rem,12px)] mt-2.5" style="color:var(--t-faint)">${(m.themes || []).length} thèmes · ${p.cours} cours · ${p.total} leçons</p> 3299 </div> 3300 <div class="shrink-0 text-right" style="width:140px"> 3301 <p class="text-[max(1.625rem,26px)] font-bold leading-none" style="color:var(--accent)">${p.part} %</p> 3302 <p class="text-[max(0.6875rem,11px)] mt-1.5 mb-2" style="color:var(--t-faint)">${p.faites} leçons sur ${p.total}</p> 3303 ${_academieBarre(p.part, 5)} 3304 </div> 3305 </div>` 3306
3307 /* ET LES SÃANCES DE CETTE MATIÃRE, ENTRE L'EN-TÃTE ET LE PROGRAMME. C'est 3308 l'endroit exact où la question se pose : on vient de voir ce que la 3309 matière contient, et on décide si on l'apprend seul ou si on attend 3310 jeudi. Plus bas, sous cent quatre-vingts leçons, personne ne la verrait ; 3311 plus haut, elle passerait devant ce qu'on est venu chercher. */ 3312 return entete 3313 + _ecoleRangeeDeSeances(_ecoleSeancesDeLaMatiere(mid), 'Séances en direct', 3314 'Sur cette matière â en direct, et les rediffusions.') 3315 + themes 3316} 3317 3318/* ââ ÃCRAN 4 : UN COURS ET SON PLAN âââââââââââââââââââââââââââââââââââââââ */ 3319function _ecoleCours(cle) { 3320 const [mid, cid] = String(cle || '').split('/') 3321 const c = window.ecoleCours(mid, cid) 3322 if (!c) return _ecoleAccueil() 3323 const m = window.ecoleMatiere(mid) 3324 const p = window.ecoleProgressionCours(mid, cid) 3325 const reprise = window.ecoleReprendreDansLeCours(mid, cid) 3326 /* LE CONTRÃLE SE DÃBLOQUE QUAND LES LEÃONS SONT FAITES. L'ouvrir d'emblée en 3327 ferait un raccourci vers l'échec â et un contrôle raté avant d'avoir appris 3328 ne mesure rien. */ 3329 const restantes = c.lecons.filter(l => l.k !== 'quiz' && !window.ecoleLeconFaite(l.cle)).length 3330 3331 const plan = c.lecons.map((l, i) => { 3332 const fait = window.ecoleLeconFaite(l.cle) 3333 const courante = reprise && reprise.cle === l.cle 3334 const verrou = l.k === 'quiz' && restantes > 0 3335 const clic = verrou ? '' : `onclick="window.ecoleVue('lecon','${escHtml(l.cle)}')"` 3336 return `<button ${clic} ${verrou ? 'disabled' : ''} class="w-full text-left flex items-center gap-3.5 px-5 py-3.5" style="border-bottom:1px solid var(--border-dim);${courante ? 'background:var(--accent-bg);border-left:3px solid var(--accent)' : verrou ? 'background:var(--bg-secondary);cursor:not-allowed' : ''}"> 3337 <span class="shrink-0 inline-flex items-center justify-center rounded-full" style="width:26px;height:26px;${fait ? 'background:var(--accent);color:#fff' : courante ? 'border:2px solid var(--accent);color:var(--accent)' : 'border:1px solid var(--border);color:var(--t-faint)'};font-size:11.5px;font-weight:700">${fait ? _icon('check', 'w-3.5 h-3.5') : verrou ? _icon('lock', 'w-3 h-3') : (i + 1)}</span> 3338 <span class="shrink-0 inline-flex items-center justify-center rounded-lg" style="width:26px;height:26px;background:${courante ? 'var(--accent)' : 'var(--bg-secondary)'};color:${courante ? '#fff' : 'var(--t-dim)'}">${_icon(l.nature.icone, 'w-3.5 h-3.5')}</span> 3339 <span class="flex-1 min-w-0 text-[max(0.90625rem,14.5px)] ${courante ? 'font-bold' : 'font-medium'}" style="color:${fait ? 'var(--t-faint)' : verrou ? 'var(--t-dim)' : 'var(--t-primary)'};${fait ? 'text-decoration:line-through' : ''}">${escHtml(l.t)}</span> 3340 <span class="shrink-0 text-[max(0.75rem,12px)]" style="color:${courante ? 'var(--accent)' : 'var(--t-faint)'}">${escHtml(l.nature.label)} · ${l.m} min${verrou ? ' · après la leçon ' + (c.lecons.length - 1) : ''}</span> 3341 </button>` 3342 }).join('') 3343 3344 return `<div class="flex items-stretch gap-5 mb-5"> 3345 <div class="flex-1 min-w-0"> 3346 <div class="flex items-center gap-2 mb-2.5"> 3347 <span class="inline-flex items-center rounded-full px-2.5 py-1 text-[max(0.6875rem,11px)] font-bold" style="background:var(--bg-secondary);color:var(--t-dim)">COURS</span> 3348 <span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${escHtml(c.niveauLisible)} · ${escHtml(m.nom)}</span> 3349 </div> 3350 <p class="text-[max(1.75rem,28px)] font-bold leading-tight" style="color:var(--t-primary)">${escHtml(c.titre)}</p> 3351 <p class="text-[max(0.9375rem,15px)] mt-3 leading-relaxed" style="color:var(--t-muted)"><strong style="color:var(--t-secondary)">à la fin de ce cours, vous saurez</strong> ${escHtml(c.objectif.charAt(0).toLowerCase() + c.objectif.slice(1))}</p> 3352 <p class="text-[max(0.75rem,12px)] mt-3.5" style="color:var(--t-faint)">${c.lecons.length} leçons · ${c.minutes} min · ${Object.entries(c.parNature).map(([k, n]) => n + ' ' + (ECOLE_NATURES[k] ? ECOLE_NATURES[k].label.toLowerCase() + (n > 1 ? 's' : '') : k)).join(', ')}</p> 3353 </div> 3354 <div class="shrink-0 flex flex-col gap-3 p-5 rounded-2xl" style="width:250px;background:var(--bg-input);border:1px solid var(--border)"> 3355 <div class="flex items-baseline gap-2">
3356 <span class="text-[max(1.75rem,28px)] font-bold leading-none" style="color:var(--accent)">${p.part} %</span> 3357 <span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${p.faites} sur ${p.total}</span> 3358 </div> 3359 ${_academieBarre(p.part, 5)} 3360 ${reprise 3361 ? `<button onclick="window.ecoleVue('lecon','${escHtml(reprise.cle)}')" class="rounded-full px-4 py-2.5 text-[max(0.875rem,14px)] font-semibold" style="background:var(--accent);color:#fff;border:none">${p.faites ? 'Reprendre à la leçon ' + (reprise.rang + 1) : 'Commencer'}</button>` 3362 : `<p class="text-[max(0.8125rem,13px)] text-center" style="color:var(--success)">Cours terminé</p>`} 3363 <p class="text-[max(0.6875rem,11px)] leading-snug text-center" style="color:var(--t-faint)">Le contrôle se débloque quand les ${c.lecons.length - 1} leçons sont faites.</p> 3364 </div> 3365 </div> 3366 <p class="text-[max(1.0625rem,17px)] font-bold mb-2.5" style="color:var(--t-primary)">Le plan du cours</p> 3367 <div class="rounded-2xl overflow-hidden" style="background:var(--bg-input);border:1px solid var(--border)">${plan}</div>` 3368} 3369 3370/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3371 * ÃCRIRE UNE LEÃON â PAR LA GORGE, ET UNE SEULE FOIS 3372 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3373 * 3374 * DEUX ENDROITS Oà UNE LEÃON PEUT ÃTRE ÃCRITE, et ils ne servent pas la même 3375 * personne : 3376 * · le STOCKAGE PARTAGà du serveur â éditorial, écrit depuis le compte qui 3377 * administre. C'est celui que tout le monde lit ; 3378 * · l'état LOCAL de qui la demande â quand le partagé ne l'a pas et qu'on 3379 * n'a pas le droit d'y écrire. Sa leçon existe quand même, pour lui. 3380 * Sans le second, une personne sans droits verrait pour toujours « pas encore 3381 * écrite » sur un bouton qui ne peut rien faire â un bouton mort. 3382 * 3383 * LE PROMPT PASSE PAR `buildSystemPrompt`, comme la séance du studio, et le 3384 * cadre vient du poste (`leconEcole`, src/app-domaine-surface.js). Ãcrire ici 3385 * « Tu es un professeur » aurait été une seconde CREA â CLAUDE.md, règle 1. 3386 */ 3387function ecoleLeconsLocales() { 3388 const x = (typeof STATE !== 'undefined' && STATE.ecoleLecons) || {} 3389 return (x && typeof x === 'object' && !Array.isArray(x)) ? x : {} 3390} 3391 3392/* CE QUI EST ÃCRIT, D'Oà QUE ÃA VIENNE. Le partagé PRIME sur le local : le 3393 jour où l'éditorial écrit une leçon, elle remplace la version que quelqu'un 3394 s'était faite â sinon deux personnes ne verraient pas la même Ãcole. */ 3395window.ecoleLeconEcrite = function (cle) { 3396 const partagees = (typeof STATE !== 'undefined' && STATE.ecoleLeconsPartagees) || {} 3397 const p = partagees[cle] 3398 if (p && p.contenu) return p.contenu 3399 return ecoleLeconsLocales()[cle] || '' 3400} 3401 3402/* LA LECTURE DU STOCKAGE PARTAGÃ. Elle demande les clés DU COURS OUVERT, pas 3403 tout : sans ça, l'écran d'une leçon télécharge toutes les leçons écrites 3404 pour en lire une, et ça ne se voit pas avant qu'il y en ait beaucoup. */ 3405window.ecoleChargerLecons = async function (cles) { 3406 const demandees = (cles || []).filter(Boolean).slice(0, 40) 3407 if (!demandees.length) return 3408 try { 3409 const r = await fetch('/api/ecole/lecons?cles=' + encodeURIComponent(demandees.join(',')), { headers: internalAuth() }) 3410 if (!r.ok) return 3411 const d = await r.json() 3412 if (!d || !d.lecons) return 3413 STATE.ecoleLeconsPartagees = Object.assign({}, STATE.ecoleLeconsPartagees || {}, d.lecons) 3414 render() 3415 } catch (e) { /* hors ligne, ou route absente : on garde ce qu'on a en local */ } 3416} 3417 3418window.ecoleEcrireLecon = async function (cle) { 3419 const [mid, cid, rang] = String(cle || '').split('/') 3420 const c = window.ecoleCours(mid, cid) 3421 const m = window.ecoleMatiere(mid) 3422 const l = c && c.lecons[Number(rang)] 3423 if (!c || !m || !l) return 3424 /* LE CONTRÃLE NE S'ÃCRIT PAS : il INTERROGE. Un texte figé ne saurait pas 3425 poser une question à quelqu'un ni juger sa réponse â ce sont les 3426 `QUESTION:{â¦}` de chaque leçon, relues par `_ecoleQuestions`, qui le 3427 font, et le contrôle de fin de cours les rassemble. */ 3428 if (l.k === 'quiz') return 3429 3430 const f = window.ecoleFiliere(m.f) 3431 const theme = (m.themes || []).find(t => (t.cours || []).some(x => x.id === cid)) 3432 const cadreEtDemande = leconEcole({ 3433 filiere: f && f.nom, matiere: m.nom, theme: theme && theme.nom, 3434 cours: c.titre, objectif: c.objectif, titre: l.t, nature: l.k, minutes: l.m, 3435 }) 3436 3437 STATE.ecoleLeconEnCours = cle 3438 STATE.ecoleLeconTexte = '' 3439 STATE._ecoleLeconErreur = '' 3440 render() 3441 try { 3442 let dernierRendu = 0 3443 const texte = await parlerAuModele({ 3444 messages: [{ role: 'user', content: cadreEtDemande.demande }], 3445 model: STATE.selectedModel?.id || STATE.models[0]?.id, 3446 systemPrompt: buildSystemPrompt(false, cadreEtDemande.demande, 'edu') + '\n\n' + cadreEtDemande.cadre, 3447 ...internalAuth(), 3448 rappels: { 3449 surTexte: (t) => { 3450 STATE.ecoleLeconTexte = t 3451 const now = Date.now() 3452 if (now - dernierRendu > 250) { dernierRendu = now; render() } 3453 }, 3454 }, 3455 }, { intention: 'agent', surface: 'edu' }) 3456 3457 STATE.ecoleLeconEnCours = null 3458 STATE.ecoleLeconTexte = '' 3459 if (!texte || texte.trim().length < 200) { 3460 /* ON NE GARDE PAS UN ÃCHEC. Une leçon de trois lignes rangée dans l'état 3461 se relirait comme une leçon écrite, et le bouton ne reviendrait plus. */ 3462 /* L'ÃCHEC SE DIT DANS L'ÃCRAN, PAS DANS UNE BULLE QUI PASSE. C'est la 3463 convention des espaces : l'erreur vit dans l'état, à côté de ce qu'elle 3464 concerne, et reste lisible tant qu'on ne l'a pas résolue. */ 3465 STATE._ecoleLeconErreur = 'la leçon est revenue trop courte â réessayez' 3466 render(); return 3467 } 3468 STATE.ecoleLecons = Object.assign({}, ecoleLeconsLocales(), { [cle]: texte }) 3469 save(); render() 3470 3471 /* ET ON LA PROPOSE AU STOCKAGE PARTAGÃ. Un refus 403 est le cas NORMAL 3472 pour qui n'administre pas : la leçon reste locale, et on ne le signale 3473 pas â ce n'est pas une panne, c'est la règle. */ 3474 try { 3475 await fetch('/api/ecole/lecons', { 3476 method: 'POST',
3477 /* `internalAuth()`, comme tous les envois de l'Académie. Un `authHeaders` 3478 inventé ici ne levait même pas à l'écriture : un identifiant jamais 3479 déclaré fait un ReferenceError DÃS le test `authHeaders ?`, et la 3480 leçon ne serait jamais proposée au stockage partagé. Trouvé par le 3481 cliquet des noms inconnus, pas par la lecture. */ 3482 headers: Object.assign({ 'Content-Type': 'application/json' }, internalAuth()), 3483 body: JSON.stringify({ cle, contenu: texte, titre: l.t, nature: l.k }), 3484 }) 3485 } catch (e) { /* pas de réseau : la leçon reste dans ce navigateur */ } 3486 } catch (e) { 3487 STATE.ecoleLeconEnCours = null 3488 STATE.ecoleLeconTexte = '' 3489 STATE._ecoleLeconErreur = String(e && e.message || e).slice(0, 120) 3490 render() 3491 } 3492} 3493 3494/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3495 * LE PANNEAU QUI SAIT D'Oà L'ON PARLE 3496 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3497 * 3498 * « Depuis une leçon, on part vers le Studio Ãducation et on perd le 3499 * contexte. » On rouvrait une conversation vierge, il fallait réexpliquer où 3500 * l'on était, et la moitié du temps on renonçait. 3501 * 3502 * Le panneau s'ouvre PAR-DESSUS l'écran, sans le quitter : la leçon reste 3503 * derrière, on revient d'un clic, et la conversation garde son fil. Il lit 3504 * `STATE.ecoleVue` et `STATE.ecoleCle` au moment d'envoyer â donc il suit 3505 * toujours l'endroit où l'on est, sans qu'on ait à le lui dire. 3506 * 3507 * ââ IL PASSE PAR LA GORGE, PAS PAR LE CHAT DES ESPACES âââââââââââââââââââ 3508 * 3509 * Les espaces écrivaient leurs personas à la main (« Tu es CREA, l'assistant 3510 * IA de la plateforme CREA⦠») â une dette `a-relier` au registre des bouches, 3511 * refermée depuis : chaque espace est un lieu de STUDIOS. 3512 * Ce panneau passe donc par `buildSystemPrompt(false, question, 'edu')`, comme 3513 * la séance et comme la leçon, et son cadre vit au poste 3514 * (`conversationEcole`, src/app-domaine-surface.js). 3515 * 3516 * ââ LE FIL EST GARDÃ, MAIS PAS ÃTERNEL âââââââââââââââââââââââââââââââââââ 3517 * 3518 * Douze échanges au plus partent au modèle : au-delà , on paie une conversation 3519 * entière pour une question de trois mots. Ce qui est à l'écran, lui, reste 3520 * entier â couper l'affichage ferait disparaître une réponse qu'on relisait. 3521 */ 3522const ACADEMIE_TOURS_ENVOYES = 12 3523 3524window.academiePanneau = function (ouvrir) { 3525 STATE.academiePanneauOuvert = ouvrir === undefined ? !STATE.academiePanneauOuvert : Boolean(ouvrir) 3526 save(); render() 3527 if (STATE.academiePanneauOuvert) setTimeout(() => document.getElementById('academie-demande')?.focus(), 40) 3528} 3529 3530/* Oà L'ON EST, AU MOMENT D'ENVOYER. Calculé ici et pas gardé dans l'état : un 3531 endroit rangé au moment d'ouvrir le panneau serait FAUX dès qu'on change de 3532 leçon derrière â et c'est précisément ce qu'on vient corriger. */ 3533function _ecoleOuJeSuis() { 3534 const vue = ECOLE_VUES.includes(STATE.ecoleVue) ? STATE.ecoleVue : 'accueil' 3535 const [mid, cid, rang] = String(STATE.ecoleCle || '').split('/') 3536 const m = mid ? window.ecoleMatiere(mid) : null 3537 const f = m ? window.ecoleFiliere(m.f) : (vue === 'filiere' ? window.ecoleFiliere(STATE.ecoleCle) : null) 3538 const c = (m && cid) ? window.ecoleCours(mid, cid) : null 3539 const theme = (m && cid) ? (m.themes || []).find(t => (t.cours || []).some(x => x.id === cid)) : null 3540 const l = (c && rang !== undefined) ? c.lecons[Number(rang)] : null 3541 return { 3542 filiere: f && f.nom, matiere: m && m.nom, theme: theme && theme.nom, 3543 cours: c && c.titre, objectif: c && c.objectif, 3544 lecon: l && l.t, nature: l && l.nature && l.nature.label, 3545 } 3546} 3547 3548/* Oà L'ON EST DANS LES TUTORIELS. Le frère de `_ecoleOuJeSuis`, et calculé au 3549 même moment â à l'ENVOI, jamais à l'ouverture du panneau : un endroit rangé 3550 quand on ouvre serait faux dès qu'on change de page derrière. */ 3551function _tutoOuJeSuis() { 3552 const vue = TUTO_VUES.includes(STATE.tutoVue) ? STATE.tutoVue : 'accueil' 3553 const [pid, tid] = String(STATE.tutoCle || '').split('/') 3554 const p = vue === 'domaine' ? null : window.tutoParcours(pid) 3555 const d = vue === 'domaine' ? window.tutoDomaine(STATE.tutoCle) : (p ? window.tutoDomaine(p.d) : null) 3556 const t = (p && tid) ? window.tutoUn(pid, tid) : null 3557 return { 3558 domaine: d && d.nom, parcours: p && p.nom, 3559 tutoriel: t && t.titre, objectif: t && t.objectif, lieu: t && t.ouvreLabel, 3560 } 3561} 3562 3563/* LA SITUATION DU PANNEAU â UNE SEULE PORTE, DEUX ENDROITS. 3564 * 3565 * Le panneau suit la personne dans TOUTE l'Académie ; il doit donc dire d'où 3566 * elle parle des deux côtés. Deux appels au modèle, un par section, auraient 3567 * donné deux façons de composer le même prompt â et le jour où l'une apprend 3568 * quelque chose, l'autre l'ignore. C'est le canal parallèle que ce dépôt passe 3569 * son temps à défaire. 3570 * 3571 * LA SURFACE CHANGE AVEC LA SECTION, ET CE N'EST PAS UN DÃTAIL. L'Ãcole parle 3572 * depuis `edu`, qui porte les outils de son domaine. Les tutoriels parlent de 3573 * CREA elle-même : leur lieu est le CHAT â `null`, « pas de lieu », le plancher 3574 * du chat général. Leur donner `edu` aurait fait monter le métier d'un studio 3575 * d'éducation par-dessus une question sur le trombone. 3576 */ 3577function _academieSituation() { 3578 const section = ACADEMIE_SECTIONS.some(s => s.id === STATE.academieSection) ? STATE.academieSection : 'tutoriels' 3579 return section === 'education' 3580 ? { ...conversationEcole(_ecoleOuJeSuis()), surface: 'edu', chemin: _ecoleCheminLisible() } 3581 : { ...conversationTutoriel(_tutoOuJeSuis()), surface: null, chemin: _tutoCheminLisible() } 3582} 3583 3584/* CE QUI S'ÃCRIT DANS L'EN-TÃTE DU PANNEAU. C'est la moitié visible du 3585 contexte : sans elle on ne peut pas SAVOIR que CREA sait d'où l'on parle, 3586 donc on le redit, et le panneau n'a servi à rien. */ 3587function _ecoleCheminLisible() { 3588 const ou = _ecoleOuJeSuis()
3589 return [ou.matiere, ou.cours, ou.lecon].filter(Boolean).join(' ⺠') 3590} 3591function _tutoCheminLisible() { 3592 const ou = _tutoOuJeSuis() 3593 return [ou.domaine, ou.parcours, ou.tutoriel].filter(Boolean).join(' ⺠') 3594} 3595 3596window.academieDemander = async function (texteDonne) { 3597 const champ = document.getElementById('academie-demande') 3598 const question = String(texteDonne || (champ && champ.value) || '').trim() 3599 if (!question || STATE.academieEnvoiEnCours) return 3600 if (champ && !texteDonne) champ.value = '' 3601 3602 STATE.academieFil = Array.isArray(STATE.academieFil) ? STATE.academieFil : [] 3603 STATE.academieFil.push({ role: 'user', content: question }) 3604 STATE.academieFil.push({ role: 'assistant', content: '', pending: true }) 3605 STATE.academieEnvoiEnCours = true 3606 STATE._academieFilErreur = '' 3607 render() 3608 3609 const idx = STATE.academieFil.length - 1 3610 try { 3611 const situation = _academieSituation() 3612 let dernierRendu = 0 3613 const texte = await parlerAuModele({ 3614 messages: STATE.academieFil.slice(0, -1).slice(-ACADEMIE_TOURS_ENVOYES).map(m => ({ role: m.role, content: m.content || '' })), 3615 model: STATE.selectedModel?.id || STATE.models[0]?.id, 3616 systemPrompt: buildSystemPrompt(false, question, situation.surface) + '\n\n' + situation.cadre, 3617 ...internalAuth(), 3618 rappels: { 3619 surTexte: (t) => { 3620 if (STATE.academieFil[idx]) STATE.academieFil[idx] = { role: 'assistant', content: t, pending: true } 3621 const now = Date.now() 3622 if (now - dernierRendu > 250) { dernierRendu = now; render() } 3623 }, 3624 }, 3625 }, { intention: 'agent', surface: situation.surface }) 3626 STATE.academieFil[idx] = { role: 'assistant', content: texte || '_(pas de réponse)_' } 3627 } catch (e) { 3628 /* ON RETIRE LA RÃPONSE EN ATTENTE. La laisser vide et « pending » ferait 3629 tourner l'indicateur pour toujours sur une réponse qui ne viendra pas. */ 3630 STATE.academieFil.splice(idx, 1) 3631 STATE._academieFilErreur = String(e && e.message || e).slice(0, 140) 3632 } 3633 STATE.academieEnvoiEnCours = false 3634 save(); render() 3635} 3636 3637/* LES TROIS DEMANDES QUI REVIENNENT. Ce sont celles de la bande du professeur, 3638 déplacées ici : elles servent partout dans l'Ãcole, pas seulement dans une 3639 leçon â et depuis un cours ou une matière, « interroge-moi » a un sens. */ 3640const ECOLE_DEMANDES = [ 3641 'Explique-moi ça autrement', 3642 'Donne-moi un exemple concret', 3643 'Interroge-moi là -dessus', 3644] 3645 3646/* ET LES TROIS AUTRES, POUR L'AUTRE SECTION. « Interroge-moi là -dessus » n'a 3647 aucun sens devant un tutoriel : on n'y révise pas, on essaie. Trois amorces 3648 qui ne collent pas à ce qu'on regarde se lisent comme un décor, et on tape 3649 sa question à côté â donc le panneau a échoué là où il devait servir. */ 3650const TUTO_DEMANDES = [ 3651 'Montre-moi ça pas à pas', 3652 'à quoi ça sert concrètement ?', 3653 'Fais-le avec moi sur mon cas', 3654] 3655 3656function _academiePanneauChat() { 3657 if (!STATE.academiePanneauOuvert) return '' 3658 const fil = Array.isArray(STATE.academieFil) ? STATE.academieFil : [] 3659 const dansLEcole = STATE.academieSection === 'education' 3660 const chemin = dansLEcole ? _ecoleCheminLisible() : _tutoCheminLisible() 3661 const amorces = dansLEcole ? ECOLE_DEMANDES : TUTO_DEMANDES 3662 return `<div class="fixed inset-0 z-40 flex justify-end" style="background:rgba(0,0,0,.28)" onclick="if(event.target===this)window.academiePanneau(false)"> 3663 <aside class="flex flex-col h-full" style="width:min(520px,100%);background:var(--bg-primary);border-left:1px solid var(--border);box-shadow:-8px 0 32px rgba(0,0,0,.12)"> 3664 <header class="shrink-0 flex items-start gap-3 px-5 py-4" style="border-bottom:1px solid var(--border)"> 3665 <span class="shrink-0 inline-flex items-center justify-center rounded-xl" style="width:34px;height:34px;background:var(--bg-secondary);border:1px solid var(--border);color:var(--accent)">${_icon('gradcap', 'w-4 h-4')}</span> 3666 <div class="flex-1 min-w-0"> 3667 <p class="text-[max(0.9375rem,15px)] font-bold leading-tight" style="color:var(--t-primary)">Demander à CREA</p> 3668 ${/* L'ENDROIT EST ÃCRIT, ET C'EST TOUT L'OBJET DU PANNEAU : on doit 3669 VOIR qu'elle sait d'où l'on parle, sinon on réexplique quand 3670 même. */''} 3671 <p class="text-[max(0.75rem,12px)] mt-0.5 truncate" style="color:var(--t-dim)">${chemin ? escHtml(chemin) : (dansLEcole ? 'depuis lâÃcole' : 'depuis les tutoriels')}</p> 3672 </div> 3673 <button onclick="window.academiePanneau(false)" class="shrink-0 rounded-lg p-1.5" style="background:none;border:none;color:var(--t-faint)">${_icon('close', 'w-4 h-4')}</button> 3674 </header> 3675 3676 <div class="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-3.5"> 3677 ${fil.length === 0 ? `<div class="flex flex-col gap-2 pt-2"> 3678 <p class="text-[max(0.84375rem,13.5px)] mb-1" style="color:var(--t-dim)">
3678Posez votre question â elle sait où vous en êtes.</p> 3679 ${amorces.map(d => `<button onclick="window.academieDemander('${escHtml(d)}')" class="text-left rounded-xl px-3.5 py-2.5 text-[max(0.84375rem,13.5px)]" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">${escHtml(d)}</button>`).join('')} 3680 </div>` : fil.map(m => m.role === 'user' 3681 ? `<div class="self-end max-w-[85%] rounded-2xl rounded-br-sm px-3.5 py-2 text-[max(0.84375rem,13.5px)]" style="background:var(--accent);color:#fff">${escHtml(m.content)}</div>` 3682 : `<div class="self-start max-w-[92%] msg-content text-[max(0.84375rem,13.5px)]" style="color:var(--t-secondary)">${m.pending && !m.content ? '<span class="opacity-50">â¦</span>' : renderMarkdown(m.content || '')}</div>` 3683 ).join('')} 3684 ${STATE._academieFilErreur ? `<p class="text-[max(0.8125rem,13px)]" style="color:var(--danger)">${escHtml(STATE._academieFilErreur)}</p>` : ''} 3685 </div> 3686 3687 <div class="shrink-0 px-4 py-3.5" style="border-top:1px solid var(--border)"> 3688 <div class="flex items-end gap-2"> 3689 <textarea id="academie-demande" rows="1" placeholder="Votre questionâ¦" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();window.academieDemander()}" class="flex-1 rounded-xl px-3.5 py-2.5 text-[max(0.875rem,14px)] outline-none resize-none" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary);max-height:120px"></textarea> 3690 <button onclick="window.academieDemander()" class="shrink-0 rounded-xl px-4 py-2.5 text-[max(0.84375rem,13.5px)] font-semibold" style="background:${STATE.academieEnvoiEnCours ? 'var(--bg-tertiary)' : 'var(--accent)'};color:${STATE.academieEnvoiEnCours ? 'var(--t-faint)' : '#fff'};border:none">${STATE.academieEnvoiEnCours ? 'â¦' : 'Envoyer'}</button> 3691 </div> 3692 </div> 3693 </aside> 3694 </div>` 3695} 3696 3697/* LE BOUTON QUI L'OUVRE â présent sur TOUS les écrans de l'Ãcole, flottant en 3698 bas à droite. Pas une barre en bas de page : c'est ce qu'on vient de retirer, 3699 parce qu'il fallait défiler pour la voir. */ 3700function _academieBoutonDemander() { 3701 if (STATE.academiePanneauOuvert) return '' 3702 const n = (STATE.academieFil || []).length 3703 return `<button onclick="window.academiePanneau(true)" class="fixed z-30 inline-flex items-center gap-2 rounded-full px-5 py-3 text-[max(0.875rem,14px)] font-semibold" style="right:24px;bottom:24px;background:var(--accent);color:#fff;border:none;box-shadow:0 4px 16px rgba(0,0,0,.18)"> 3704 ${_icon('gradcap', 'w-4 h-4')}Demander à CREA${n ? `<span class="rounded-full px-1.5 text-[max(0.6875rem,11px)]" style="background:rgba(255,255,255,.24)">${Math.ceil(n / 2)}</span>` : ''} 3705 </button>` 3706} 3707 3708/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3709 * LES QUESTIONS D'UNE LEÃON â LE MÃME LECTEUR QUE LE SERVEUR 3710 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3711 * 3712 * Le serveur refuse à l'entrée une question dont « bonne » sort des choix 3713 * (server/ecole-lecon.js). L'écran doit lire EXACTEMENT la même chose, sinon 3714 * les deux divergent : une leçon écrite localement â donc jamais passée par le 3715 * serveur â afficherait des boutons dont aucun n'est juste. 3716 * 3717 * On ne peut pas importer le module du serveur ici : ce fichier est un script 3718 * de navigateur. On réécrit donc le lecteur, et une garde EXÃCUTE les deux sur 3719 * les mêmes entrées pour vérifier qu'ils décident pareil. C'est la seule forme 3720 * de copie que ce dépôt accepte : celle qu'une garde tient accordée. 3721 */ 3722function _ecoleQuestions(contenu) { 3723 const out = [] 3724 const re = /^QUESTION:(\{.*\})\s*$/gm 3725 let m 3726 while ((m = re.exec(String(contenu || '')))) { 3727 let q = null 3728 try { q = JSON.parse(m[1]) } catch (e) { continue } 3729 const choix = Array.isArray(q.choix) ? q.choix.map(c =>
3729 String(c == null ? '' : c).trim()).filter(Boolean) : [] 3730 const bonne = Number(q.bonne) 3731 if (!q.q || typeof q.q !== 'string' || !q.q.trim()) continue 3732 if (choix.length < 2) continue 3733 if (!Number.isInteger(bonne) || bonne < 0 || bonne >= choix.length) continue 3734 out.push({ q: String(q.q).trim(), choix, bonne, pourquoi: String(q.pourquoi || '').trim() }) 3735 } 3736 return out 3737} 3738 3739/* LE CORPS SANS LES QUESTIONS. Les laisser dans le Markdown les afficherait 3740 DEUX fois : une fois en JSON brut au fil du texte, une fois en boutons. */ 3741function _ecoleCorpsSansQuestions(contenu) { 3742 return String(contenu || '').replace(/^QUESTION:\{.*\}\s*$/gm, '').replace(/\n{3,}/g, '\n\n').trim() 3743} 3744 3745/* RÃPONDRE. L'état n'est PAS persisté : c'est une aide à la lecture, pas une 3746 note. Le persister ferait qu'on retrouve ses réponses six mois plus tard 3747 sans se rappeler la question â et surtout, ça figerait une leçon qui peut 3748 être réécrite entre-temps. */ 3749window.ecoleRepondre = function (cle, i, choisi) { 3750 STATE._ecoleReponses = STATE._ecoleReponses || {} 3751 const k = cle + '#' + i 3752 if (STATE._ecoleReponses[k] !== undefined) return // on ne répond qu'une fois 3753 STATE._ecoleReponses[k] = Number(choisi) 3754 render() 3755} 3756 3757function _ecoleBlocQuestions(cle, questions) { 3758 if (!questions.length) return '' 3759 const rep = STATE._ecoleReponses || {} 3760 return `<section class="rounded-2xl overflow-hidden mb-5" style="background:var(--bg-input);border:1px solid var(--border)"> 3761 <p class="px-5 pt-4 pb-1 text-[max(0.8125rem,13px)] font-bold" style="color:var(--t-secondary)">Vérifier que c'est compris</p> 3762 ${questions.map((q, i) => { 3763 const donnee = rep[cle + '#' + i] 3764 const repondu = donnee !== undefined 3765 return `<div class="px-5 py-4" style="border-top:1px solid var(--border-dim)"> 3766 <p class="text-[max(0.90625rem,14.5px)] font-semibold mb-2.5" style="color:var(--t-primary)">${i + 1}. ${escHtml(q.q)}</p> 3767 <div class="flex flex-col gap-1.5"> 3768 ${q.choix.map((c, j) => { 3769 const juste = j === q.bonne 3770 const choisi = donnee === j 3771 const fond = !repondu ? 'var(--bg-secondary)' 3772 : juste ? 'color-mix(in srgb,var(--success) 12%,transparent)' 3773 : choisi ? 'color-mix(in srgb,var(--danger) 12%,transparent)' : 'var(--bg-secondary)' 3774 const bord = !repondu ? 'var(--border)' : juste ? 'var(--success)' : choisi ? 'var(--danger)' : 'var(--border)' 3775 return `<button ${repondu ? '' : `onclick="window.ecoleRepondre('${escHtml(cle)}',${i},${j})"`} class="text-left rounded-xl px-3.5 py-2 text-[max(0.84375rem,13.5px)] flex items-center gap-2.5" style="background:${fond};border:1px solid ${bord};color:var(--t-secondary)"> 3776 <span class="shrink-0 inline-flex items-center justify-center rounded-full text-[max(0.6875rem,11px)] font-bold" style="width:20px;height:20px;background:var(--bg-input);border:1px solid ${bord};color:var(--t-dim)">${String.fromCharCode(65 + j)}</span> 3777 <span class="flex-1">${escHtml(c)}</span> 3778 ${repondu && juste ? _icon('check', 'w-4 h-4') : ''} 3779 </button>` 3780 }).join('')} 3781 </div> 3782 ${repondu && q.pourquoi ? `<p class="text-[max(0.8125rem,13px)] mt-2.5 leading-snug" style="color:var(--t-dim)">${escHtml(q.pourquoi)}</p>` : ''} 3783 </div>` 3784 }).join('')} 3785 </section>` 3786} 3787 3788/* ââ ÃCRAN 5 : LA LEÃON âââââââââââââââââââââââââââââââââââââââââââââââââââ */ 3789/* `ECOLE_AIDES` EST PARTIE DANS LE PANNEAU. Ces trois demandes ne servaient 3790 que dans une leçon et ouvraient le studio ; elles sont maintenant 3791 `ECOLE_DEMANDES`, et elles valent partout dans l'Ãcole â depuis un cours ou 3792 une matière, « interroge-moi » a un sens lui aussi. */ 3793 3794function _ecoleLecon(cle) { 3795 const [mid, cid, rang] = String(cle || '').split('/') 3796 const c = window.ecoleCours(mid, cid) 3797 if (!c) return _ecoleAccueil() 3798 const l = c.lecons[Number(rang)] 3799 if (!l) return _ecoleCours(mid + '/' + cid) 3800 const m = window.ecoleMatiere(mid) 3801 const suivante = c.lecons[Number(rang) + 1] 3802 const precedente = c.lecons[Number(rang) - 1] 3803 const fait = window.ecoleLeconFaite(l.cle) 3804
3805 const jauge = `<div class="flex items-center gap-4 px-5 py-3.5 mb-5 rounded-2xl" style="background:var(--bg-input);border:1px solid var(--border)"> 3806 <button onclick="window.ecoleVue('cours','${escHtml(mid + '/' + cid)}')" class="shrink-0 inline-flex items-center gap-1.5 text-[max(0.75rem,12px)] font-medium" style="color:var(--t-dim);background:none;border:none">${_icon('chevronLeft', 'w-3.5 h-3.5')}${escHtml(c.titre)}</button> 3807 <span class="flex-1 flex items-center gap-1">${c.lecons.map(x => `<span class="flex-1 rounded-full" style="height:4px;background:${window.ecoleLeconFaite(x.cle) ? 'var(--accent)' : x.cle === l.cle ? 'color-mix(in srgb,var(--accent) 45%,transparent)' : 'var(--bg-tertiary)'}"></span>`).join('')}</span> 3808 <span class="shrink-0 text-[max(0.75rem,12px)] font-semibold" style="color:var(--t-dim)">Leçon ${Number(rang) + 1} sur ${c.lecons.length}</span> 3809 </div>` 3810 3811 /* ââ LE CORPS DE LA LEÃON ââââââââââââââââââââââââââââââââââââââââââââââââ 3812 * 3813 * Il disait « le contenu de cette leçon n'est pas encore branché ». C'était 3814 * honnête, et c'était mieux qu'un lecteur vidéo mort â mais mille cent 3815 * trente-neuf leçons qui disent toutes la même phrase, ce n'est plus une 3816 * école, c'est un sommaire. 3817 * 3818 * TROIS ÃTATS, ET ILS NE SE RESSEMBLENT PAS : 3819 * · ÃCRITE â le texte, rendu par le Markdown qui existe déjà ; 3820 * · EN COURS D'ÃCRITURE â ce qui arrive, au fil de l'écriture ; 3821 * · PAS ENCORE â un bouton, et la phrase honnête d'avant. 3822 * Confondre le troisième avec « en train de charger » ferait tourner un 3823 * indicateur pour toujours sur une leçon que personne n'a demandée. */ 3824 const ecrite = window.ecoleLeconEcrite(l.cle) 3825 const enCours = STATE.ecoleLeconEnCours === l.cle 3826 /* LE CORPS ET LES QUESTIONS SE SÃPARENT AVANT LE RENDU. Laissées dans le 3827 Markdown, les lignes `QUESTION:{â¦}` s'afficheraient en JSON brut au fil du 3828 texte ET en boutons plus bas â deux fois la même chose, dont une illisible. 3829 `renderMarkdown` pose au passage le lecteur des vidéos : un lien YouTube 3830 dans la leçon devient un vrai lecteur, sans contrat de plus. */ 3831 const questions = ecrite ? _ecoleQuestions(ecrite) : [] 3832 const corps = ecrite 3833 ? `<article class="msg-content rounded-2xl px-6 py-5 mb-5" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${renderMarkdown(_ecoleCorpsSansQuestions(ecrite))}</article>` 3834 + _ecoleBlocQuestions(l.cle, questions) 3835 : enCours 3836 ? `<div class="rounded-2xl px-6 py-5 mb-5" style="background:var(--bg-input);border:1px solid var(--border)"> 3837 <p class="text-[max(0.8125rem,13px)] font-semibold mb-2" style="color:var(--accent)">CREA écrit cette leçonâ¦</p> 3838 <div class="msg-content" style="color:var(--t-secondary)">${renderMarkdown(STATE.ecoleLeconTexte || '')}</div> 3839 </div>` 3840 : `<div class="rounded-2xl overflow-hidden mb-5" style="background:var(--bg-input);border:1px solid var(--border)"> 3841 <div class="relative flex items-center justify-center" style="aspect-ratio:16/9;background:#101314"> 3842 <span class="inline-flex items-center justify-center rounded-full" style="width:62px;height:62px;background:rgba(255,255,255,.14);border:1.5px solid rgba(255,255,255,.5);color:#fff">${_icon(l.nature.icone, 'w-6 h-6')}</span> 3843 <span class="absolute" style="bottom:14px;left:18px;color:rgba(255,255,255,.86);font-size:12px;font-weight:600">${escHtml(l.nature.label)} · ${l.m} min</span> 3844 </div> 3845 <div class="p-5 flex items-center gap-4"> 3846 <p class="flex-1 text-[max(0.9375rem,15px)] leading-relaxed" style="color:var(--t-muted)">Cette leçon nâest pas encore écrite. CREA peut la rédiger maintenant, à partir du cours et de son objectif â et elle restera écrite.</p> 3847 <button onclick="window.ecoleEcrireLecon('${escHtml(l.cle)}')" class="shrink-0 rounded-full px-5 py-2.5 text-[max(0.875rem,14px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ãcrire la leçon</button> 3848 </div> 3849 ${STATE._ecoleLeconErreur ? `<div class="px-5 pb-4"><p class="text-[max(0.8125rem,13px)]" style="color:var(--danger)">${escHtml(STATE._ecoleLeconErreur)}</p></div>` : ''} 3850 </div>` 3851 3852 /* LE PROFESSEUR N'EST PLUS UNE BANDE DE BOUTONS QUI PART AILLEURS. Les trois 3853 demandes ouvraient le Studio Ãducation par le geste des amorces : on 3854 quittait la leçon, et on perdait l'endroit. Elles ouvrent maintenant le 3855 panneau, qui reste PAR-DESSUS la leçon et sait d'où l'on parle. */ 3856 const prof = `<div class="flex items-center gap-3.5 px-5 py-4 mb-5 rounded-2xl" style="background:var(--bg-secondary);border:1px solid var(--border)"> 3857 <span class="shrink-0 inline-flex items-center justify-center rounded-xl" style="width:34px;height:34px;background:var(--bg-input);border:1px solid var(--border);color:var(--accent)">${_icon('gradcap', 'w-4 h-4')}</span> 3858 <span class="shrink-0 text-[max(0.84375rem,13.5px)] font-semibold" style="color:var(--t-secondary)">Bloqué sur cette leçon ?</span> 3859 <span class="flex-1 flex flex-wrap gap-2">${ECOLE_DEMANDES.map(d => `<button onclick="window.academiePanneau(true);window.academieDemander('${escHtml(d)}')" class="rounded-full px-3.5 py-1.5 text-[max(0.78125rem,12.5px)] font-medium" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${escHtml(d)}</button>`).join('')}</span> 3860 </div>` 3861 3862 const nav = `<div class="flex items-center justify-between gap-4"> 3863 ${precedente 3864 ? `<button onclick="window.ecoleVue('lecon','${escHtml(precedente.cle)}
3864')" class="inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-[max(0.84375rem,13.5px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${_icon('chevronLeft', 'w-4 h-4')}${escHtml(precedente.t.slice(0, 40))}</button>` 3865 : '<span></span>'} 3866 <button onclick="window.ecoleFaite('${escHtml(l.cle)}'${suivante ? `,'${escHtml(suivante.cle)}'` : ''})" class="inline-flex items-center gap-2 rounded-full px-6 py-3 text-[max(0.90625rem,14.5px)] font-semibold" style="background:${fait ? 'var(--bg-input)' : 'var(--accent)'};color:${fait ? 'var(--accent)' : '#fff'};border:${fait ? '1px solid var(--accent)' : 'none'}"> 3867 ${fait ? 'Déjà faite' : 'Marquer comme faite'}${suivante ? ', et continuer' : ''} ${_icon('chevronRight', 'w-4 h-4')} 3868 </button> 3869 </div>` 3870 3871 return jauge + `<div class="flex items-center gap-2 mb-2"> 3872 <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[max(0.71875rem,11.5px)] font-bold" style="background:var(--accent-bg);color:var(--accent)">${_icon(l.nature.icone, 'w-3 h-3')}${escHtml(l.nature.label.toUpperCase())}</span> 3873 <span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${l.m} min · ${escHtml(m.nom)} ⺠${escHtml(c.titre)}</span> 3874 </div> 3875 <p class="text-[max(1.6875rem,27px)] font-bold leading-tight mb-4" style="color:var(--t-primary)">${escHtml(l.t)}</p>` 3876 + corps + prof + nav 3877} 3878 3879/* LE FIL D'ARIANE â il dit où l'on est, et chaque cran ramène. Sans lui, on 3880 descend quatre étages et le seul retour est le bouton du navigateur. */ 3881function _ecoleFil() { 3882 const vue = ECOLE_VUES.includes(STATE.ecoleVue) ? STATE.ecoleVue : 'accueil' 3883 if (vue === 'accueil') return '' 3884 const cle = String(STATE.ecoleCle || '') 3885 const crans = [{ l: 'Ãcole', v: 'accueil', c: null }] 3886 const [mid, cid] = cle.split('/') 3887 const m = vue === 'filiere' ? null : window.ecoleMatiere(mid) 3888 const f = vue === 'filiere' ? window.ecoleFiliere(cle) : (m ? window.ecoleFiliere(m.f) : null) 3889 if (f) crans.push({ l: f.nom, v: 'filiere', c: f.id }) 3890 if (m) crans.push({ l: m.nom, v: 'matiere', c: m.id }) 3891 if (m && cid && (vue === 'cours' || vue === 'lecon')) { 3892 const c = window.ecoleCours(mid, cid) 3893 if (c) crans.push({ l: c.titre, v: 'cours', c: mid + '/' + cid }) 3894 } 3895 return `<div class="flex flex-wrap items-center gap-1.5 mb-4 text-[max(0.78125rem,12.5px)]">${crans.map((x, i) => ` 3896 <button onclick="window.ecoleVue('${x.v}'${x.c ? `,'${escHtml(x.c)}'` : ''})" style="color:${i === crans.length - 1 ? 'var(--t-secondary)' : 'var(--t-faint)'};background:none;border:none;font-weight:${i === crans.length - 1 ? '600' : '500'}">${escHtml(x.l)}</button> 3897 ${i < crans.length - 1 ? `<span style="color:var(--t-faint)">âº</span>` : ''}`).join('')}</div>` 3898} 3899 3900function _academieEducation() { 3901 const vue = ECOLE_VUES.includes(STATE.ecoleVue) ? STATE.ecoleVue : 'accueil' 3902 const cle = STATE.ecoleCle 3903 /* ON DEMANDE LES LEÃONS DU COURS OUVERT, ET UNE SEULE FOIS PAR COURS. Le 3904 rendu est appelé à chaque frappe : sans ce garde-fou, l'écran interrogerait 3905 le serveur des dizaines de fois par seconde. Et on demande les clés du 3906 COURS, pas de la leçon seule â on va lire les suivantes. */ 3907 if (vue === 'cours' || vue === 'lecon') { 3908 const [mid, cid] = String(cle || '').split('/') 3909 const c = window.ecoleCours(mid, cid) 3910 if (c && STATE._ecoleLeconsDemandees !== mid + '/' + cid) { 3911 STATE._ecoleLeconsDemandees = mid + '/' + cid 3912 window.ecoleChargerLecons(c.lecons.map(x => x.cle)) 3913 } 3914 } 3915 const corps = vue === 'filiere' ? _ecoleFiliere(cle) 3916 : vue === 'matiere' ? _ecoleMatiere(cle) 3917 : vue === 'cours' ? _ecoleCours(cle) 3918 : vue === 'lecon' ? _ecoleLecon(cle) 3919 : _ecoleAccueil() 3920 /* LE PANNEAU ET SON BOUTON SONT RENDUS PAR LA SECTION, pas par chaque écran : 3921 ils suivent la personne partout dans l'Ãcole, et c'est exactement ce qui 3922 manquait â la porte vers CREA n'existait que dans une leçon. */ 3923 /* LE PANNEAU ET SON BOUTON SONT MONTÃS D'UN ÃTAGE. Ils étaient rendus ici, 3924 donc dans l'Ãcole seule ; ils suivent maintenant la personne dans TOUTE 3925 l'Académie, et c'est `renderAcademieSpace` qui les pose â une fois, pour 3926 les deux sections. Les laisser aussi ici en aurait affiché deux. */ 3927 return _ecoleFil() + corps 3928} 3929
3930/* LES DEUX SECTIONS, EN UN SEUL BLOC. C'étaient deux grosses gélules vertes 3931 portant chacune son libellé ET sa glose â « Ãcole Apprendre une matière ». 3932 Deux boutons de tailles différentes, l'un criard, l'autre gris : ça se lit 3933 comme une promotion, pas comme une navigation. 3934 Un interrupteur segmenté dit la même chose en plus calme : un seul fond, un 3935 curseur clair sur la section active, et la glose descend dans le sous-titre 3936 de la page â où elle était déjà , sans faire doublon. */ 3937function _academieOnglets(section) { 3938 return `<div class="inline-flex gap-0.5 p-0.5 rounded-xl shrink-0" style="background:var(--bg-tertiary)">${ACADEMIE_SECTIONS.map(s => ` 3939 <button onclick="window.academieSection('${s.id}')" class="rounded-[10px] px-4 py-1.5 text-[max(0.8125rem,13px)] font-semibold transition-colors" style="${section === s.id ? 'background:var(--bg-input);color:var(--t-primary);box-shadow:0 1px 2px rgba(0,0,0,.08)' : 'background:none;color:var(--t-dim)'};border:none"> 3940 ${escHtml(s.label)} 3941 </button>`).join('')}</div>` 3942} 3943 3944/* ââ LA BARRE DE SECTION â L'INTERRUPTEUR, LA PORTE VERS CREA, LES RÃGLAGES 3945 * 3946 * « à côté du bouton tutoriel là , tu vas mettre un bouton qui est lié au fait 3947 * de pouvoir questionner CREA. » 3948 * 3949 * Il y était déjà , mais SEULEMENT en bas de page, et seulement dans une 3950 * section : une barre de saisie qu'il fallait faire défiler pour trouver. Le 3951 * même geste, remonté à côté de l'interrupteur, se voit sans chercher â et 3952 * ouvre le panneau, qui sait d'où l'on parle. 3953 * 3954 * LES RÃGLAGES SONT UNE ROUE, PAS UNE TROISIÃME SECTION. Une section est un 3955 * CONTENU qu'on parcourt ; les réglages sont un endroit où l'on va faire une 3956 * chose puis d'où l'on revient. Les mettre dans l'interrupteur les aurait mis 3957 * à égalité avec l'Ãcole, et on serait tombé dessus en cherchant un cours. 3958 */ 3959function _academieBarreDeSection(section) { 3960 return `<div class="flex items-center gap-2 shrink-0"> 3961 ${_academieOnglets(section)} 3962 <button onclick="window.academiePanneau(true)" class="inline-flex items-center gap-1.5 rounded-xl px-3.5 py-1.5 text-[max(0.8125rem,13px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)"> 3963 ${_icon('talk', 'w-3.5 h-3.5')}Demander à CREA 3964 </button> 3965 <button onclick="window.academieVue('reglages')" title="Réglages de l'Académie" class="inline-flex items-center justify-center rounded-xl" style="width:32px;height:32px;background:var(--bg-input);border:1px solid var(--border);color:var(--t-dim)"> 3966 ${_icon('gear', 'w-4 h-4')} 3967 </button> 3968 </div>` 3969} 3970 3971function renderAcademieSpace() { 3972 const section = ACADEMIE_SECTIONS.some(s => s.id === STATE.academieSection) ? STATE.academieSection : 'tutoriels' 3973 let vue = section === 'education' ? 'education' : (STATE.academieVue || 'apercu') 3974 // Le refus vaut aussi au RENDU : un état déjà posé ne doit pas passer. 3975 if (vue === 'registre' && isClientView()) vue = 'apercu' 3976 const cle = STATE.academieCle 3977 const role = vue === 'role' ? ACADEMIE_ROLES.find(r => r.id === cle) : null 3978 const fiche = vue === 'fiche' ? _academieCatalogue().find(r => r.type + ':' + r.id === cle) : null 3979 const lieuOk = vue === 'lieu' && _academieLieux().includes(cle) 3980 const fil = role ? role.label 3981 : lieuOk ? _academieTitreDuLieu(cle) 3982 : vue === 'registre' ? 'Le registre' 3983 : vue === 'reglages' ? "Réglages de l'Académie" 3984 : vue === 'ressources' ? 'Toutes les ressources' 3985 : fiche ? fiche.titre : '' 3986 const sousTitre = vue === 'registre' ? "Les sources branchées, ce qui en a été importé, et sous quelle licence." 3987 : vue === 'reglages' ? "Le bilan, les annonces, le dépôt â et, pour le propriétaire, le registre." 3988 : vue === 'ressources' ? "Tous les tutoriels et cas d'usage de l'Académie, en un seul endroit." 3989 : role ? "Prompts et cas d'usage pour ce métier." 3990 : lieuOk ? 'Ce qui se pratique ici.' 3991 : fiche ? fiche.quoi 3992 : section === 'education' ? "Huit filières, cinquante-six matières â des cours à suivre, pas des questions à poser." 3993 : "Huit domaines, vingt-cinq tutoriels â des gestes à faire, pas des fiches à lire." 3994 const corps = section === 'education' ? _academieEducation() 3995 : vue === 'registre' ? _academieRegistre() 3996 : vue === 'reglages' ? _academieReglages() 3997 : role ? _academiePageDeRole(role) 3998 : lieuOk ? _academiePageDeLieu(cle) 3999 : vue === 'ressources' ? _academieRessources() 4000 : fiche ? _academieFiche(cle) 4001 : _academieTutoriels() 4002 return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell"> 4003 ${_spaceMenuBtn()} 4004 <div class="flex-1 overflow-y-auto min-h-0"> 4005 ${/* LA LARGEUR EST LA MÃME POUR LES DEUX SECTIONS, MAINTENANT QU'ELLES 4006 ONT LA MÃME FORME. Le `max-w-3xl` de l'espace vaut 768 px â la bonne 4007 mesure pour une colonne de TEXTE. Un CATALOGUE de cartes couchées 4008 dedans n'a la place que d'une par rangée, et le reste s'empile : 4009 c'était tout le reproche, « ça fait trop bloc empilé ». Les deux 4010 colonnes prennent donc 1120 px ; les pages de TEXTE â une fiche, un 4011 rôle, le registre, les réglages â gardent 768, parce qu'élargir une 4012 colonne de texte la rend moins lisible, pas plus. */''} 4013 <div class="${fil ? 'max-w-3xl' : 'max-w-[1120px]'} mx-auto px-4 pt-8 pb-6"> 4014 ${_academieEnTete(fil, sousTitre, !!role, fil ? '' : _academieBarreDeSection(section))} 4015 ${corps} 4016 ${/* LA BARRE DE QUESTION EN BAS DE PAGE A QUITTà LES DEUX SECTIONS. 4017 Elle avait déjà quitté l'Ãcole, où elle était la panne : on 4018 descendait quatre étages pour arriver à une leçon, et sous la 4019 leçon il y avait une barre de saisie â le chat, en plus long. 4020 Aux tutoriels elle avait le même défaut, en moins visible : il 4021 fallait faire défiler toute la page pour la trouver, et ce qu'on y 4022 tapait ne savait rien de la page. Signalé : « la barre de 4023 questions tout en bas là , il faut supprimer, et puis même tous les 4024 autres blocs que tu as dessinés en bas ». 4025 Le panneau la remplace partout : il s'ouvre par-dessus, il sait 4026 d'où l'on parle, et il ne se cherche pas. */''} 4027 </div> 4028 </div> 4029 ${_academieBoutonDemander()} 4030 ${_academiePanneauChat()} 4031 </main>` 4032} 4033 4034const SANTE_PILLS = [{ label: 'Symptômes', icon: _icon('health') }, { label: 'Médicaments', icon: _icon('science') }, { label: 'Nutrition', icon: _icon('heart') }, { label: 'Santé mentale', icon: _icon('humanities') }, { label: 'Urgences', icon: _icon('law') }] 4035const SANTE_TOPICS = [ 4036 { c1: '#059669', c2: '#0d9488', q: 'nutrition et alimentation équilibrée conseils santé', label: 'Nutrition & bien-être', img: 'macro photo of fresh colorful vegetables and fruits, healthy food photography, natural light', seed: 31 }, 4037 { c1: '#7c3aed', c2: '#4338ca', q: 'santé mentale gestion du stress bien-être psychologique', label: 'Santé mentale', img: 'calm serene photo of meditation stones and soft natural light, wellness photography, peaceful mood', seed: 32 }, 4038 { c1: '#dc2626', c2: '#b91c1c', q: 'prévention dépistage examens médicaux', label: 'Prévention & dépistage', img: 'macro photo of a stethoscope on a clean white medical desk, healthcare photography, soft lighting', seed: 33 }, 4039] 4040// Une variation de poids qui monte n'est ni bonne ni mauvaise : ce module ne 4041// porte AUCUN jugement médical. La flèche dit le sens, la couleur reste neutre. 4042function _santeCarte(cle, t) { 4043 const v = t.dernier?.valeurs || {} 4044 const lisible = Object.values(v).map(x =>
4044 (Math.round(x * 10) / 10).toLocaleString('fr-FR')).join(' / ') 4045 const sens = t.variation == null ? '' : t.variation > 0.5 ? 'â' : t.variation < -0.5 ? 'â' : 'â' 4046 return `<div class="rounded-xl p-3" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4047 <p class="text-[max(0.6875rem,11px)] mb-1" style="color:var(--t-faint)">${escHtml(t.libelle)}</p> 4048 <p class="text-[max(1.25rem,20px)] font-bold leading-none" style="color:var(--t-primary)">${escHtml(lisible)}<span class="text-[max(0.6875rem,11px)] font-medium ml-1" style="color:var(--t-faint)">${escHtml(t.unite)}</span></p> 4049 <p class="text-[max(0.6875rem,11px)] mt-1.5" style="color:var(--t-faint)">${ 4050 t.variation == null 4051 // « Pas encore de comparaison » n'est pas « stable » : le dire évite de 4052 // faire croire à une mesure qui ne bouge pas. 4053 ? `${t.nb} mesure${t.nb > 1 ? 's' : ''} · pas encore de comparaison` 4054 : `${sens} ${Math.abs(t.variation).toFixed(1).replace('.', ',')} % sur 7 jours` 4055 }</p> 4056 </div>` 4057} 4058function _santeSaisie(cle, def) { 4059 const champs = def.champs.map(c => `<input id="sante-${cle}-${c.cle}" type="number" step="any" inputmode="decimal" 4060 placeholder="${escHtml(def.champs.length > 1 ? c.cle : def.unite)}" 4061 class="w-full rounded-lg px-2.5 py-1.5 text-[max(0.8125rem,13px)]" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary)" />`).join('') 4062 return `<div class="flex items-center gap-2"> 4063 <span class="text-[max(0.75rem,12px)] shrink-0" style="color:var(--t-secondary);width:86px">${escHtml(def.libelle)}</span> 4064 <div class="flex gap-1.5 flex-1 min-w-0">${champs}</div> 4065 <button onclick="window.santeAjouter('${escHtml(cle)}')" class="shrink-0 rounded-lg px-2.5 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ajouter</button> 4066 </div>` 4067}
4068function renderSanteSpace() { 4069 const d = (STATE.spaceSearch || {}).espaceSante || {} 4070 const s = (STATE.spaceData || {}).espaceSante || {} 4071 return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell"> 4072 ${_spaceMenuBtn()} 4073 <div class="flex-1 overflow-y-auto min-h-0"> 4074 <div class="max-w-3xl mx-auto px-4 pt-12 pb-6"> 4075 ${_spaceTitre('Santé', 'text-[max(2.375rem,38px)] font-bold', 'color:var(--t-primary)', { centre: true, marge: 'mb-8', chevron: 'w-6 h-6' })} 4076 <div class="mb-4">${renderUniversalInputBar({ inputId: 'space-input-espaceSante', sendId: 'btn-space-send-espaceSante', micId: 'btn-space-mic-espaceSante', placeholder: "Posez n'importe quelle question de santé", rows: 1 })}</div> 4077 ${_spaceChatArea('espaceSante', SPACE_SUGGESTIONS.espaceSante, true)} 4078 <div class="flex gap-2.5 overflow-x-auto pb-5" style="scrollbar-width:none">${SANTE_PILLS.map(p => `<button data-space-pill-espaceSante="${escHtml(p.label)}" class="shrink-0 inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-[max(0.875rem,14px)] font-medium" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">${p.icon}${p.label}</button>`).join('')}</div> 4079 <div class="rounded-xl p-3.5 mb-5 flex items-start gap-2" style="background:var(--accent-bg);border:1px solid var(--border)"> 4080 ${_icon('health', 'w-4 h-4 shrink-0 mt-0.5')} 4081 <p class="text-[max(0.71875rem,11.5px)] leading-relaxed" style="color:var(--t-secondary)">Informations générales, pas un avis médical â en cas d'urgence ou de doute, consultez un professionnel de santé.</p> 4082 </div> 4083 ${d.loading ? `<p class="text-center py-8 text-[max(0.875rem,14px)]" style="color:var(--t-dim)">Rechercheâ¦</p>` : 4084 d.results ? _searchResultsList(d.results, 'Aucun résultat.') : ` 4085 <!-- MON RELEVà â ce qui distingue un espace d'une coquille. Tenu à la 4086 main, rangé dans le dossier du compte, transmis à personne. --> 4087 <p class="text-[max(0.875rem,14px)] font-bold mb-3" style="color:var(--t-primary)">Mon relevé</p> 4088 ${Object.keys(s.tendances || {}).length ? `<div class="grid gap-2.5 mb-4" style="grid-template-columns:repeat(auto-fill,minmax(148px,1fr))"> 4089 ${Object.entries(s.tendances).map(([k, t]) => _santeCarte(k, t)).join('')} 4090 </div>` : `<p class="text-[max(0.78125rem,12.5px)] mb-4" style="color:var(--t-faint)">Aucune mesure pour l'instant. Ajoutez-en une ci-dessous et les tendances apparaîtront.</p>`} 4091 <div class="rounded-xl p-3.5 mb-2 flex flex-col gap-2" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4092 ${Object.entries(s.mesures || {}).map(([k, def]) => _santeSaisie(k, def)).join('')} 4093 </div> 4094 ${s.saisieErreur ? `<p class="text-[max(0.75rem,12px)] mb-4 rounded-lg px-3 py-2" style="background:rgba(239,68,68,.08);color:#ef4444;border:1px solid rgba(239,68,68,.2)">${escHtml(s.saisieErreur)}</p>` : ''} 4095 ${s.journalError ? `<p class="text-[max(0.75rem,12px)] mb-4" style="color:var(--t-faint)">Relevé indisponible â ${escHtml(s.journalError)}</p>` : ''} 4096 ${(s.journal || []).length ? `<details class="mb-5"><summary class="text-[max(0.78125rem,12.5px)] cursor-pointer" style="color:var(--t-dim)">Historique (${s.journal.length})</summary> 4097 <div class="flex flex-col gap-1 mt-2">${s.journal.slice(0, 60).map(e => `<div class="flex items-center justify-between text-[max(0.75rem,12px)] rounded-lg px-2.5 py-1.5" style="background:var(--bg-secondary);color:var(--t-secondary)"> 4098 <span>${escHtml((s.mesures?.[e.type]?.libelle) || e.type)} · ${escHtml(Object.values(e.valeurs || {}).join(' / '))} ${escHtml(s.mesures?.[e.type]?.unite || '')}</span> 4099 <span class="flex items-center gap-2"><span style="color:var(--t-faint)">${escHtml(new Date(e.at).toLocaleDateString('fr-FR'))}</span> 4100 <button onclick="window.santeSupprimer(${jsAttr(e.id)})" style="background:none;border:none;color:var(--t-faint);cursor:pointer">â</button></span> 4101 </div>`).join('')}</div></details>` : ''} 4102 4103 <!-- MÃDICAMENTS â la base publique de l'ANSM, pas la mémoire du modèle. --> 4104 <p class="text-[max(0.875rem,14px)] font-bold mb-2" style="color:var(--t-primary)">Médicaments</p> 4105 <div class="flex gap-2 mb-2"> 4106 <input id="sante-medoc-q" type="search" placeholder="Nom d'un médicament ou d'une substance" onkeydown="if(event.key==='Enter')window.santeChercherMedicament()" 4107 class="flex-1 rounded-lg px-3 py-2 text-[max(0.8125rem,13px)]" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary)" /> 4108 <button onclick="window.santeChercherMedicament()" class="rounded-lg px-3 py-2 text-[max(0.78125rem,12.5px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Chercher</button> 4109 </div> 4110 ${s.medocChargement ? '<p class="text-[max(0.78125rem,12.5px)] mb-4" style="color:var(--t-dim)">Recherche dans la base publiqueâ¦</p>' : ''} 4111 ${s.medocRaison ? `<p class="text-[max(0.78125rem,12.5px)] mb-4" style="color:var(--t-faint)">${escHtml(s.medocRaison)}</p>` : ''} 4112 ${(s.medocs || []).length ? `<p class="text-[max(0.71875rem,11.5px)] mb-2" style="color:var(--t-faint)">${s.medocTotal} résultat${s.medocTotal > 1 ? 's' : ''} â source : base de données publique des médicaments (ANSM), Licence Ouverte</p> 4113 <div class="flex flex-col gap-1.5 mb-5">${s.medocs.map(m =>
4113 `<div class="rounded-xl px-3 py-2.5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4114 <p class="text-[max(0.8125rem,13px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(m.nom)}</p> 4115 <p class="text-[max(0.71875rem,11.5px)] mt-0.5" style="color:var(--t-secondary)">${escHtml(m.substances.map(x => x.nom + (x.dosage ? ' ' + x.dosage : '')).join(' · ') || 'â')}</p> 4116 <p class="text-[max(0.6875rem,11px)] mt-1" style="color:var(--t-faint)">${escHtml(m.forme)}${m.voies ? ' · ' + escHtml(m.voies) : ''}${m.titulaire ? ' · ' + escHtml(m.titulaire) : ''}</p> 4117 ${!m.commercialise ? '<p class="text-[max(0.6875rem,11px)] mt-1" style="color:var(--t-faint)">â Non commercialisé actuellement</p>' : ''} 4118 ${m.surveillanceRenforcee ? '<p class="text-[max(0.6875rem,11px)] mt-1" style="color:#d97706">â² Sous surveillance renforcée</p>' : ''} 4119 </div>`).join('')}</div>` : ''} 4120 4121 <!-- Dit une fois, clairement, plutôt que promis puis jamais livré. --> 4122 <p class="text-[max(0.71875rem,11.5px)] leading-relaxed mb-5 rounded-xl p-3" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-faint)"> 4123 Ce relevé est tenu à la main et reste dans votre compte. <strong>Aucune connexion à un compte Ameli n'est possible</strong> : l'Assurance Maladie n'ouvre pas d'accès aux applications tierces, et Créa ne vous demandera jamais ces identifiants. Pour vos remboursements, téléchargez le relevé depuis votre espace et déposez-le dans une conversation â Créa sait le lire. 4124 </p> 4125 4126 ${(s.news || []).length ? `<p class="text-[max(0.875rem,14px)] font-bold mb-3" style="color:var(--t-primary)">Actualité santé</p> 4127 <div class="flex flex-col gap-2 mb-5">${s.news.slice(0, 6).map(a => `<button onclick="window.CREA_INTERNE && window.CREA_INTERNE.lireArticle({url:${jsAttr(a.url)},titre:${jsAttr(a.title || '')},image:${jsAttr(a.image || '')},source:${jsAttr(a.source || '')}})" class="text-left rounded-xl px-3 py-2.5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4128 <p class="text-[max(0.8125rem,13px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(a.title || '')}</p> 4129 <p class="text-[max(0.6875rem,11px)] mt-1" style="color:var(--t-faint)">${escHtml(a.source || '')}</p> 4130 </button>`).join('')}</div>` : ''} 4131 4132 <div class="flex items-center justify-between mb-4"> 4133 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Sujets tendance</p> 4134 <button data-space-scroll="sante-topics" style="color:var(--t-faint)">${_icon('chevronRight', 'w-5 h-5')}</button> 4135 </div> 4136 <div id="sante-topics" class="flex gap-4 overflow-x-auto pb-2" style="scrollbar-width:none">${SANTE_TOPICS.map(t => _spaceImageCard(t.img, t.label, t.c1, t.c2, `data-space-topic-espaceSante="${escHtml(t.q)}"`, t.seed)).join('')}</div> 4137 `} 4138 </div> 4139 </div> 4140 </main>` 4141} 4142 4143const BREVETS_PILLS = [{ label: 'Patentability', icon: _icon('search') }, { label: 'Technology Landscape', icon: _icon('network') }, { label: 'R&D', icon: _icon('edit') }] 4144const BREVETS_CATEGORIES = [ 4145 { c1: '#1e3a8a', c2: '#1e40af', q: 'brevets informatique et logiciels récents', label: 'Computers & Software', img: 'macro photo of a blue printed circuit board microchip, dramatic lighting, tech product photography', seed: 21 }, 4146 { c1: '#0891b2', c2: '#0e7490', q: 'brevets technologies médicales et santé', label: 'Medical & Healthcare Technology', img: 'macro photo of clear glass medical capsule pills, dark blue background, pharmaceutical product photography', seed: 22 }, 4147 { c1: '#475569', c2: '#334155', q: 'brevets machines industrielles', label: 'Industrial Machinery', img: 'macro photo of a shiny metallic industrial
4147coil spring, dark background, dramatic studio lighting', seed: 23 }, 4148] 4149function renderBrevetsSpace() { 4150 const d = (STATE.spaceSearch || {}).espaceBrevets || {} 4151 return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell"> 4152 ${_spaceMenuBtn()} 4153 <div class="flex-1 overflow-y-auto min-h-0"> 4154 <div class="max-w-3xl mx-auto px-4 pt-12 pb-6"> 4155 ${_spaceTitre('Brevets', 'text-[max(2.375rem,38px)] font-bold', 'color:var(--t-primary)', { centre: true, marge: 'mb-8', chevron: 'w-6 h-6' })} 4156 <div class="mb-4">${renderUniversalInputBar({ inputId: 'space-input-espaceBrevets', sendId: 'btn-space-send-espaceBrevets', micId: 'btn-space-mic-espaceBrevets', placeholder: "Posez n'importe quelle question sur les brevets", rows: 1 })}</div> 4157 ${_spaceChatArea('espaceBrevets', SPACE_SUGGESTIONS.espaceBrevets, true)} 4158 <div class="flex gap-2.5 overflow-x-auto pb-5" style="scrollbar-width:none">${BREVETS_PILLS.map(p => `<button data-space-pill-espaceBrevets="${escHtml(p.label)}" class="shrink-0 inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-[max(0.875rem,14px)] font-medium" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">${p.icon}${p.label}</button>`).join('')}</div> 4159 <details class="rounded-xl mb-5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4160 <summary class="flex items-center justify-between px-4 py-3 cursor-pointer select-none list-none text-[max(0.875rem,14px)] font-medium" style="color:var(--t-primary)">Nouveau dans la recherche de brevets ? Apprenez à utiliser cet outil ${_icon('chevronDown', 'w-5 h-5 shrink-0')}</summary> 4161 <p class="px-4 pb-3.5 text-[max(0.78125rem,12.5px)] leading-relaxed" style="color:var(--t-dim)">Posez une question en langage naturel, filtrez par Patentability / Technology Landscape / R&D, ou partez d'une catégorie ci-dessous â CREA cherche sur le web et résume les résultats pertinents.</p> 4162 </details> 4163 ${d.loading ? `<p class="text-center py-8 text-[max(0.875rem,14px)]" style="color:var(--t-dim)">Rechercheâ¦</p>` : 4164 d.results ? _searchResultsList(d.results, 'Aucun résultat.') : ` 4165 <div class="flex items-center justify-between mb-4"> 4166 <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Explorer par catégorie</p> 4167 <button data-space-scroll="brevets-topics" style="color:var(--t-faint)">${_icon('chevronRight', 'w-5 h-5')}</button> 4168 </div> 4169 <div id="brevets-topics" class="flex gap-4 overflow-x-auto pb-2" style="scrollbar-width:none">${BREVETS_CATEGORIES.map(t => _spaceImageCard(t.img, t.label, t.c1, t.c2, `data-space-topic-espaceBrevets="${escHtml(t.q)}"`, t.seed)).join('')}</div> 4170 `} 4171 </div> 4172 </div> 4173 </main>` 4174} 4175 4176 4177/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4178 * L'ESPACE COMPÃTENCES â chercher, absorber, faire jouer. 4179 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4180 * 4181 * « Ajoute un espace skill dans Créa, connecte ce skill à Créa, et absorbe et 4182 * rends possible tout ce skill via cet espace et autre. » 4183 * 4184 * Les neuf autres espaces regardent un DOMAINE â l'actualité, les marchés, la 4185 * santé. Celui-ci regarde ce que Créa SAIT FAIRE, et il est le seul dont le 4186 * contenu modifie l'organisme : une compétence absorbée ici entre dans le 4187 * prompt système de TOUTE l'application (`buildSystemPrompt`), pas seulement 4188 * dans le fil de cet écran. C'est la moitié « et autre » de la demande, et 4189 * c'est celle qu'un catalogue rate toujours. 4190 * 4191 * TROIS ÃTATS, ET ILS NE SE RESSEMBLENT PAS : 4192 * · TROUVÃE elle existe sur le marché ; Créa n'en a que la fiche. 4193 * · ABSORBÃE son texte est allé chercher dans son dépôt et rangé ici. 4194 * · ACTIVE ce texte entre dans le prompt â c'est là , et l
4194à seulement, 4195 * qu'elle agit. 4196 * Confondre les trois donnerait le défaut classique du magasin d'extensions : 4197 * un bouton « installer » qui ne fait qu'allumer une pastille verte. 4198 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 4199 4200/* CE QUI RESTAIT DE L'ESPACE COMPÃTENCES. 4201 * 4202 * « Je ne t'ai même pas demandé de créer un espace, mais tu es parti en créer 4203 * un espace compétence. » 4204 * 4205 * L'espace est parti ; ses gestes étaient restés. Huit fonctions posées sur 4206 * `window`, un état à eux, et la lecture d'un champ de recherche qu'aucun 4207 * rendu ne produit plus. Rien ne les appelait : ni le 4208 * Répertoire, ni une page, ni un onclick. Le scanner de branchements le 4209 * disait depuis le premier passage. 4210 * 4211 * C'est le pire genre de reste : du code qui a l'air vivant. On le lit, on 4212 * croit tenir la recherche de compétences, et on modifie une fonction que 4213 * personne n'exécute. Le vrai écran est `_renderCompetences` dans app.js. 4214 * 4215 * `_skillsPourPrompt` est parti avec `_common` : les compétences entrent dans 4216 * le prompt des espaces par la gorge (`CREA_SKILLS.texteDuFocus`), comme dans 4217 * le chat et les studios â un seul fil, pas un par surface. 4218 */ 4219 4220// Relit les comptes quand la personne REVIENT dans Créa, une seule fois. 4221// Sans ce guet, il faudrait recharger la page à la main pour voir ses comptes â 4222// et personne ne devine qu'il faut le faire. 4223function _cfoGuetterRetour() { 4224 if (window._cfoGuetActif) return 4225 window._cfoGuetActif = true 4226 const auRetour = () => { 4227 if (document.visibilityState === 'hidden') return 4228 window.removeEventListener('focus', auRetour) 4229 document.removeEventListener('visibilitychange', auRetour) 4230 window._cfoGuetActif = false 4231 // Bridge a besoin d'un instant pour publier les comptes après la 4232 // synchronisation : relire à la milliseconde où l'onglet reprend le focus 4233 // ramènerait souvent une liste encore vide. 4234 setTimeout(() => window.cfoRafraichirBanque(), 1200) 4235 } 4236 window.addEventListener('focus', auRetour) 4237 document.addEventListener('visibilitychange', auRetour) 4238} 4239 4240// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4241// TROIS BRANCHES DE PLUS â PROJETS, RELATIONS, JURIDIQUE 4242// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4243// 4244// « Quelle autre branche aussi on peut rajouter dans cette liste ? » 4245// 4246// Les six branches existantes regardent toutes le MONDE : l'actualité, les 4247// marchés, la santé publique, l'éducation, les brevets. Aucune ne regardait le 4248// travail de celui qui les ouvre. C'est le trou qu'on comble ici. 4249// 4250// ââ LA RÃGLE QU'ON S'EST DONNÃE : PAS DE COQUILLE âââââââââââââââââââââââ 4251// 4252// Une branche vide qui promet un domaine est pire qu'une branche absente : on 4253// clique, on ne trouve rien, et on cesse de cliquer sur les autres. Chacune des 4254// trois s'appuie donc sur quelque chose qui existe déjà : 4255// 4256// PROJETS window.CREA_PROJETS.PROJETS â mission, statut, objectifs, 4257// tensions et liens, écrits et déjà utilisés pour nourrir les
4258// réponses de CREA. Ils n'étaient VISIBLES nulle part : elle en 4259// parlait, on ne pouvait pas les regarder. 4260// RELATIONS window.CREA_PERSONNES.load() â même situation exactement. 4261// JURIDIQUE aucun module, et on ne fait pas semblant. Elle s'appuie sur le 4262// moteur du Fil avec la rubrique « Droit & Réglementation », et 4263// sur l'état de conformité RÃEL du compte. C'est peu, mais c'est 4264// vrai, et ça se dit. 4265 4266// Petit cadre commun : les trois branches se ressemblent, et devaient se 4267// ressembler â sinon on aurait trois écrans qui n'ont en commun que d'être 4268// nouveaux. 4269function _brancheCadre(titre, sousTitre, corps) { 4270 return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell"> 4271 ${_spaceMenuBtn()} 4272 <div class="flex-1 overflow-y-auto min-h-0"> 4273 <div class="w-full px-4 md:px-8 pt-3 pb-10 mx-auto" style="max-width:1100px"> 4274 <div class="mb-1">${_spaceTitre(titre, 'text-[max(1.1875rem,19px)] font-bold', 'color:var(--t-primary)')}</div> 4275 <p class="text-[max(0.75rem,12px)] mb-5" style="color:var(--t-dim)">${escHtml(sousTitre)}</p> 4276 ${corps} 4277 </div> 4278 </div> 4279 </main>` 4280} 4281 4282// Un état vide qui DIT pourquoi il est vide. « Aucun projet » et « le module 4283// n'a pas chargé » ne se ressemblent pas, et les confondre ferait chercher au 4284// mauvais endroit â c'est la règle absent â panne, appliquée à un écran. 4285function _brancheVide(message) { 4286 return `<p class="text-center py-14 text-[max(0.78125rem,12.5px)]" style="color:var(--t-dim)">${escHtml(message)}</p>` 4287} 4288 4289function renderProjetsSpace() { 4290 const M = (window.CREA_PROJETS && window.CREA_PROJETS.PROJETS) || null 4291 const objectifs = ((STATE.spaceData || {}).espaceProjets || {}).goals || [] 4292 const chargement = ((STATE.spaceData || {}).espaceProjets || {}).loading 4293 const dossiers = STATE.folders || [] 4294 4295 // ââ VOS DOSSIERS SONT VOS PROJETS RÃELS ââââââââââââââââââââââââââââââââ 4296 // 4297 // « Pour Projet, il y a beaucoup de choses qui les relient : il y a les 4298 // dossiers, il y a la bibliothèque, il y a les sessions. » 4299 // 4300 // La première version de cet écran ne montrait que la carte figée de 4301 // `CREA_PROJETS` â mission, tensions, liens. C'est le CADRE, et il était 4302 // juste. Mais le travail, lui, vit dans `STATE.folders` : les 4303 // conversations, les médias, les tâches, le journal. Deux notions de 4304 // « projet » coexistaient dans l'application, et cette branche n'en voyait 4305 // qu'une â celle qui ne bouge jamais. 4306 // 4307 // On ne recopie pas la vue de dossier : elle existe (`openProject`), elle est 4308 // riche, et la redessiner ici la ferait diverger. On donne la porte. 4309 const carteDossier = (f) => { 4310 const convs = (STATE.conversations || []).filter(c => (f.convIds || []).includes(c.id)) 4311 const derniere = convs.map(c => c.updatedAt || c.createdAt || 0).sort((a, b) => b - a)[0] 4312 const taches = (f.tasks || []) 4313 const faites = taches.filter(t => t.done).length 4314 return `<div class="rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4315 <div class="flex items-start gap-2 mb-1.5"> 4316 ${f.icon ? `<span class="text-[max(0.9375rem,15px)] shrink-0">${escHtml(f.icon)}</span>` : ''} 4317 <p class="text-[max(0.84375rem,13.5px)] font-bold flex-1 min-w-0 truncate" style="color:var(--t-primary)">${escHtml(f.name || 'Dossier')}</p> 4318 </div> 4319 <div class="flex flex-wrap gap-1.5 mb-3"> 4320 <span class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--bg-input);color:var(--t-faint)">${convs.length} session${convs.length > 1 ? 's' : ''}</span> 4321 ${taches.length ? `<span class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--bg-input);color:var(--t-faint)">${faites}/${taches.length} tâches</span>` : ''} 4322 ${derniere ? `<span class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--bg-input);color:var(--t-faint)">${escHtml(new Date(derniere).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }))}</span>` : ''} 4323 </div> 4324 <div class="flex flex-wrap gap-1.5"> 4325 <button onclick="STATE.activeProjectId='${escHtml(f.id)}';STATE.folderActiveId=null;
4325openProject('${escHtml(f.id)}')" class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Ouvrir le dossier</button> 4326 <button data-vers-le-fil='${escHtml(JSON.stringify({ recherche: f.name || '', depuis: 'espaceProjets', libelle: 'Projets' }))}' class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Ce qu'on en dit</button> 4327 </div> 4328 </div>` 4329 } 4330 4331 const carteProjet = (nom) => { 4332 const p = M[nom] || {} 4333 const tensions = (p.tensions || []).filter(Boolean) 4334 // LE LIEN ENTRE LE CADRE ET LE TRAVAIL. Quels dossiers parlent de ce 4335 // projet ? La détection existait déjà (`CREA_PROJETS.detecter`) et ne 4336 // servait qu'à nourrir les réponses : elle n'avait jamais rien MONTRÃ. 4337 const lies = dossiers.filter(f => { 4338 try { 4339 const texte = (f.name || '') + ' ' + (f.keywords || []).join(' ') + ' ' + (f.description || '') 4340 return (window.CREA_PROJETS.detecter(texte) || []).some(x => (x.nom || x) === nom) 4341 } catch (e) { return false } 4342 }) 4343 return `<div class="rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4344 <div class="flex items-start justify-between gap-3 mb-1.5"> 4345 <p class="text-[max(0.84375rem,13.5px)] font-bold" style="color:var(--t-primary)">${escHtml(nom)}</p> 4346 ${p.statut ? `<span class="shrink-0 rounded-full px-2 py-0.5 text-[max(0.625rem,10px)] font-semibold" style="background:var(--bg-input);color:var(--t-secondary)">${escHtml(p.statut)}</span>` : ''} 4347 </div> 4348 ${p.mission ? `<p class="text-[max(0.71875rem,11.5px)] leading-relaxed mb-2.5" style="color:var(--t-dim)">${escHtml(p.mission)}</p>` : ''} 4349 ${(p.objectifs || []).length ? `<p class="text-[max(0.625rem,10px)] font-semibold uppercase tracking-wide mb-1" style="color:var(--t-faint)">Objectifs</p> 4350 <ul class="mb-2.5 space-y-1">${p.objectifs.map(o => `<li class="text-[max(0.71875rem,11.5px)] pl-3" style="color:var(--t-secondary);text-indent:-9px">â ${escHtml(o)}</li>`).join('')}</ul>` : ''} 4351 ${tensions.length ? `<p class="text-[max(0.625rem,10px)] font-semibold uppercase tracking-wide mb-1" style="color:#b45309">Tensions</p> 4352 <ul class="mb-2.5 space-y-1">${tensions.map(t => `<li class="text-[max(0.71875rem,11.5px)] pl-3" style="color:var(--t-secondary);text-indent:-9px">â ${escHtml(t)}</li>`).join('')}</ul>` : ''} 4353 ${lies.length ? `<p class="text-[max(0.625rem,10px)] font-semibold uppercase tracking-wide mb-1" style="color:var(--t-faint)">Vos dossiers sur ce projet</p> 4354 <div class="flex flex-wrap gap-1.5 mb-2.5">${lies.map(f => `<button onclick="STATE.activeProjectId='${escHtml(f.id)}';STATE.folderActiveId=null;openProject('${escHtml(f.id)}')" class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--accent-bg);color:var(--accent)">${escHtml(f.icon || '')} ${escHtml(f.name)}</button>`).join('')}</div>` : ''} 4355 ${(p.liens || []).length ? `<div class="flex flex-wrap gap-1.5 mb-2.5">${p.liens.map(l => `<span class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--bg-input);color:var(--t-faint)">${escHtml(l)}</span>`).join('')}</div>` : ''} 4356 <button data-vers-le-fil='${escHtml(JSON.stringify({ recherche: nom, depuis: 'espaceProjets', libelle: 'Projets' }))}' class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Ce qu'on en dit aujourd'hui</button> 4357 </div>` 4358 } 4359 4360 const corps = ` 4361 <p class="text-[max(0.8125rem,13px)] font-bold mb-2" style="color:var(--t-primary)">Vos dossiers</p> 4362 ${dossiers.length 4363 ? `<div class="grid gap-3 md:grid-cols-3 mb-6">${dossiers.slice(0, 24).map(carteDossier).join('')}</div>` 4364 : `<p class="text-[max(0.75rem,12px)] mb-6" style="color:var(--t-dim)">Aucun dossier pour l'instant â un dossier rassemble des sessions, des médias et des tâches autour d'un même sujet.</p>`} 4365 4366 <p class="text-[max(0.8125rem,13px)] font-bold mb-2" style="color:var(--t-primary)">Les projets de l'écosystème</p> 4367 ${!M 4368 ? _brancheVide("Le module des projets n'a pas chargé â ce n'est pas qu'il n'y a aucun projet.") 4369 : `<div class="grid gap-3 md:grid-cols-2 mb-6">${Object.keys(M).map(carteProjet).join('')}</div>`} 4370 4371 <!-- LES OBJECTIFS CHIFFRÃS VIENNENT DU SERVEUR (/api/goals), pas d'ici : 4372 ils changent, les projets non. Les mélanger dans une même source 4373 obligerait à redéployer pour cocher une case. --> 4374 <div class="flex items-center justify-between gap-3 mb-2"> 4375 <p class="text-[max(0.8125rem,13px)] font-bold" style="color:var(--t-primary)">Objectifs suivis</p> 4376 <button id="btn-projets-rafraichir" class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)]" style="border:1px solid var(--border);color:var(--t-secondary)">Rafraîchir</button> 4377 </div> 4378 ${chargement && !objectifs.length 4379 ? `<p class="text-[max(0.75rem,12px)]" style="color:var(--t-dim)">Chargement des objectifsâ¦</p>` 4380 : objectifs.length 4381 ? `<div class="space-y-2">${objectifs.slice(0, 40).map(g => { 4382 const pct = Math.max(0, Math.min(100, Number(g.progress) || 0)) 4383 return `<div class="rounded-xl px-3.5 py-2.5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4384 <div class="flex items-center justify-between gap-3 mb-1.5"> 4385 <p class="text-[max(0.78125rem,12.5px)] font-medium truncate" style="color:var(--t-primary)">${escHtml(g.title || 'Sans titre')}</p>
4386 <span class="text-[max(0.6875rem,11px)] tabular-nums shrink-0" style="color:var(--t-faint)">${pct} %${g.deadline ? ' · ' + escHtml(String(g.deadline).slice(0, 10)) : ''}</span> 4387 </div> 4388 <div style="height:5px;border-radius:99px;background:var(--bg-input);overflow:hidden"> 4389 <div style="height:100%;width:${pct}%;background:${g.done ? '#16a34a' : 'var(--accent)'}"></div> 4390 </div> 4391 </div>` 4392 }).join('')}</div>` 4393 : _brancheVide('Aucun objectif suivi pour le moment.')}` 4394 4395 /* ââ UN DOMAINE SANS ENDROIT Oà ÃCRIRE N'EST PAS UN DOMAINE ââââââââââââ 4396 * 4397 * MESURà : Projets déclarait un chat en QUATRE endroits â trois suggestions 4398 * dans SPACE_SUGGESTIONS, un lieu dans STUDIOS, un profil d'outils dans 4399 * PROFILS_DE_SURFACE, un appel à bindSpaceChat au démarrage â et n'avait 4400 * aucun champ où écrire. `bindSpaceChat` sortait à sa PREMIÃRE ligne, faute 4401 * de bouton d'envoi. C'est le défaut exact que ce fichier raconte pour 4402 * Découvrir en tête de page, et il était resté ici. 4403 * 4404 * EN HAUT, PAS EN BAS. Les trois espaces qui parlent (Ãducation, Santé, 4405 * Brevets) posent leur barre au-dessus du contenu et leur fil juste 4406 * dessous : `enLigne` met la conversation dans le flux de la page. La barre 4407 * épinglée en bas a été retirée de trois autres espaces parce qu'elle 4408 * mangeait 36 à 41 % d'un écran de portable â on ne la ramène pas ici. */ 4409 const parole = `<div class="mb-4">${renderUniversalInputBar({ inputId: 'space-input-espaceProjets', sendId: 'btn-space-send-espaceProjets', micId: 'btn-space-mic-espaceProjets', placeholder: 'Où en sont vos projets, ce qui bloque, ce qui vient', rows: 1 })}</div> 4410 ${_spaceChatArea('espaceProjets', SPACE_SUGGESTIONS.espaceProjets, true)}` 4411 4412 return _brancheCadre('Projets', 'Vos dossiers, les projets qui les cadrent, et ce qu\'on en dit dehors.', parole + corps) 4413} 4414 4415function renderRelationsSpace() { 4416 const gens = (window.CREA_PERSONNES && window.CREA_PERSONNES.load) ? window.CREA_PERSONNES.load() : null 4417 const dossiers = STATE.folders || [] 4418 4419 // ââ UNE RELATION N'EXISTE PAS TOUTE SEULE ââââââââââââââââââââââââââââââ 4420 // 4421 // « Relation, c'est lié à Coworker, c'est lié à Collab aussi, et c'est lié à 4422 // Communauté. » 4423 // 4424 // La première version listait des fiches et s'arrêtait là : un annuaire. 4425 // Or une personne n'est pas une fiche, c'est un point de passage â elle 4426 // apparaît dans des dossiers, on monte quelque chose avec elle dans Collab, on la 4427 // croise dans Communauté, et Coworker est l'endroit d'où l'on agit. Sans ces 4428 // portes, il faut ressortir au menu et se rappeler soi-même le lien : le 4429 // travail que la machine aurait dû faire. 4430 const surfaces = [ 4431 { wm: 'coworker', mode: true, libelle: 'Coworker', aide: 'Agir avec elle : tâches, suivi' }, 4432 { wm: 'collab', libelle: 'Collab', aide: 'Faire quelque chose ensemble : projet, appel, coup de main' }, 4433 { wm: 'community', libelle: 'Communauté', aide: 'La croiser : profils, publications' }, 4434 ] 4435 4436 let corps 4437 if (!gens) { 4438 corps = _brancheVide("Le module des relations n'a pas chargé â ce n'est pas qu'il n'y a personne.") 4439 } else if (!gens.length) { 4440 corps = _brancheVide('Personne d\'enregistré pour le moment.') 4441 } else { 4442 const carte = (p) => { 4443 const nom = p.nom || p.id || 'Sans nom' 4444 // Les dossiers où cette personne apparaît. `detecter` existait et ne 4445 // servait qu'à nourrir les réponses : elle n'avait jamais rien montré. 4446 const lies = dossiers.filter(f => { 4447 try { 4448 const texte = (f.name || '') + ' ' + (f.keywords || []).join(' ') + ' ' + (f.description || '') 4449 return (window.CREA_PERSONNES.detecter(texte) || []).some(x => (x.id || x) === p.id) 4450 } catch (e) { return false } 4451 }) 4452 return `<div class="rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4453 <div class="flex items-start justify-between gap-3 mb-1"> 4454 <p class="text-[max(0.84375rem,13.5px)] font-bold" style="color:var(--t-primary)">${escHtml(nom)}</p> 4455 ${p.statut ? `<span class="shrink-0 rounded-full px-2 py-0.5 text-[max(0.625rem,10px)] font-semibold" style="background:var(--bg-input);color:var(--t-secondary)">${escHtml(p.statut)}</span>` : ''} 4456 </div> 4457 ${p.role ? `<p class="text-[max(0.71875rem,11.5px)] font-medium mb-1.5" style="color:var(--accent)">${escHtml(p.role)}</p>` : ''} 4458 ${p.contexte ? `<p class="text-[max(0.71875rem,11.5px)] leading-relaxed mb-2.5" style="color:var(--t-dim)">${escHtml(p.contexte)}</p>` : ''} 4459 ${(p.projets || []).length ? `<div class="flex flex-wrap gap-1.5 mb-2.5">${p.projets.map(x => `<span class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--bg-input);color:var(--t-faint)">${escHtml(x)}</span>`).join('')}</div>` : ''} 4460 ${lies.length ? `<p class="text-[max(0.625rem,10px)] font-semibold uppercase tracking-wide mb-1" style="color:var(--t-faint)">Vos dossiers où elle apparaît</p> 4461 <div class="flex flex-wrap gap-1.5 mb-2.5">${lies.map(f => `<button onclick="STATE.activeProjectId='${escHtml(f.id)}';STATE.folderActiveId=null;
4461openProject('${escHtml(f.id)}')" class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--accent-bg);color:var(--accent)">${escHtml(f.icon || '')} ${escHtml(f.name)}</button>`).join('')}</div>` : ''} 4462 <button data-vers-le-fil='${escHtml(JSON.stringify({ recherche: nom, depuis: 'espaceRelations', libelle: 'Relations' }))}' class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Ce qu'on en dit</button> 4463 </div>` 4464 } 4465 corps = ` 4466 <!-- LES TROIS SURFACES Oà UNE RELATION SE VIT. Elles existent depuis 4467 longtemps et cette branche les ignorait : on listait des gens sans 4468 pouvoir rien faire avec eux. --> 4469 <div class="grid gap-2 md:grid-cols-3 mb-6"> 4470 ${surfaces.map(s => `<button onclick="${s.mode ? `openModeTab('${s.wm}')` : `STATE.workMode='${s.wm}';save();render()`}" class="text-left rounded-2xl px-3.5 py-3" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4471 <p class="text-[max(0.78125rem,12.5px)] font-bold mb-0.5" style="color:var(--t-primary)">${escHtml(s.libelle)}</p> 4472 <p class="text-[max(0.6875rem,11px)]" style="color:var(--t-dim)">${escHtml(s.aide)}</p> 4473 </button>`).join('')} 4474 </div> 4475 <p class="text-[max(0.8125rem,13px)] font-bold mb-2" style="color:var(--t-primary)">Les personnes</p> 4476 <div class="grid gap-3 md:grid-cols-2">${gens.map(carte).join('')}</div>` 4477 } 4478 return _brancheCadre('Relations', 'Les personnes autour des projets â et les endroits où l\'on travaille avec elles.', corps) 4479} 4480 4481function renderJuridiqueSpace() { 4482 // ââ ON NE FAIT PAS SEMBLANT ââââââââââââââââââââââââââââââââââââââââââ 4483 // 4484 // Aucun module juridique n'existe dans ce dépôt. Cette branche ne prétend 4485 // donc pas conseiller : elle rassemble ce qui EST vrai â une veille 4486 // réglementaire tirée du moteur du Fil, et l'état de conformité réel du 4487 // compte, lu dans l'état, pas inventé. 4488 // 4489 // Et elle le DIT, en haut, à la première ligne. Une branche qui laisserait 4490 // croire à un avis juridique serait pire qu'absente. 4491 const d = (STATE.spaceData || {}).espaceJuridique || {} 4492 const articles = d.articles || [] 4493 const conformite = [ 4494 ['Conditions acceptées', !!STATE.privacyTerms], 4495 ['Consentement RGPD', !!STATE.rgpdConsent], 4496 ['Données d\'entraînement', STATE.dataTrainingConsent === true ? true : STATE.dataTrainingConsent === false ? false : null], 4497 ['Mode confidentiel', !!STATE.privacyMode], 4498 ] 4499 const pastille = (v) => v === null 4500 ? `<span class="rounded-full px-2 py-0.5 text-[max(0.625rem,10px)] font-semibold" style="background:var(--bg-input);color:var(--t-faint)">non renseigné</span>` 4501 : `<span class="rounded-full px-2 py-0.5 text-[max(0.625rem,10px)] font-semibold" style="background:${v ? 'rgba(22,163,74,.14)' : 'var(--bg-input)'};color:${v ? '#16a34a' : 'var(--t-faint)'}">${v ? 'oui' : 'non'}</span>` 4502 4503 const corps = ` 4504 <div class="rounded-2xl p-3.5 mb-5" style="background:var(--bg-input);border:1px solid var(--border)"> 4505 <p class="text-[max(0.71875rem,11.5px)] leading-relaxed" style="color:var(--t-dim)">Cette branche n'est pas un conseil juridique et n'en tient pas lieu. Elle rassemble une veille réglementaire et l'état de votre compte â rien de plus.</p> 4506 </div> 4507 <p class="text-[max(0.8125rem,13px)] font-bold mb-2" style="color:var(--t-primary)">Votre conformité</p> 4508 <div class="grid gap-2 md:grid-cols-2 mb-6"> 4509 ${conformite.map(([libelle, v]) => `<div class="flex items-center justify-between gap-3 rounded-xl px-3.5 py-2.5" style="background:var(--bg-secondary);border:1px solid var(--border)"> 4510 <span class="text-[max(0.75rem,12px)]" style="color:var(--t-secondary)">${escHtml(libelle)}</span>${pastille(v)} 4511 </div>`).join('')} 4512 </div> 4513 <div class="flex items-center justify-between gap-3 mb-2"> 4514 <p class="text-[max(0.8125rem,13px)] font-bold" style="color:var(--t-primary)">Veille réglementaire</p> 4515 <button id="btn-juridique-rafraichir" class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)]" style="border:1px solid var(--border);color:var(--t-secondary)">Rafraîchir</button> 4516 </div> 4517 ${d.loading && !articles.length 4518 ? `<p class="text-[max(0.75rem,12px)]" style="color:var(--t-dim)">Lecture des sourcesâ¦</p>` 4519 : d.juridiqueError 4520 ? `<p class="text-[max(0.75rem,12px)]" style="color:var(--t-dim)">
4520Veille indisponible â ${escHtml(d.juridiqueError)}.</p>` 4521 : articles.length 4522 // ââ LES MÃMES CARTES QUE LE FIL, PAS DES BLOCS à PART âââââââââ 4523 // 4524 // « Ãa bloque sur bloc â normalement ça devrait être comme Le Fil. » 4525 // 4526 // Redessiner ici des vignettes d'actualité, c'est se donner sa 4527 // propre date, son propre repli d'image, sa propre marque de 4528 // lecture â et diverger au premier changement. `CREA_FIL.carte` est 4529 // LA carte : l'article lu ici est lu là -bas, l'article gardé ici est 4530 // gardé là -bas. Une actualité est une actualité, quelle que soit la 4531 // porte par laquelle on la regarde. 4532 ? `<div class="dec-grille">${articles.slice(0, 12).map(a => window.CREA_FIL ? window.CREA_FIL.carte(a, false) : '').join('')}</div>` 4533 : _brancheVide('Aucune actualité réglementaire pour le moment.')} 4534 <!-- ET ON PEUT ALLER PLUS LOIN, SANS PERDRE LE CHEMIN DU RETOUR. --> 4535 <div class="mt-4"> 4536 <button data-vers-le-fil='${escHtml(JSON.stringify({ sujet: 'Droit & Réglementation', depuis: 'espaceJuridique', libelle: 'Juridique' }))}' class="rounded-full px-4 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="background:var(--t-primary);color:var(--bg-primary)">Tout voir dans Le Fil</button> 4537 </div>` 4538 return _brancheCadre('Juridique', 'Veille réglementaire et état de conformité de votre compte.', corps) 4539}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.