PageSourceSearch

https://creaconnect.world/src/crea-communaute.js

js creaconnect.world collected 2026-09-24 10:08:35 UTC 31,015 bytes, 533 lines download raw bytes

1/* ═══════════════════════════════════════════════════════════════════════════
2 * L'ESPACE COMMUNAUTÉ — TREIZIÈME MORCEAU DÉTACHÉ DE src/app.js
3 * ═══════════════════════════════════════════════════════════════════════════
4 * CREA COMMUNAUTE — le lien de personne à groupe : cercles, événements,
5 *                   entraide, ressources partagées
6 *
7 *
8 * « Le manque, dans ce que je viens de te définir, c'est l'espace
9 *  communautaire. Il faut créer un vrai espace communautaire, qui vient se
10 *  lier à l'espace réseau — mais un espace communautaire, c'est beaucoup,
11 *  beaucoup de choses. »
12 *
13 * ── LA LIGNE QUI SÉPARE RÉSEAU ET COMMUNAUTÉ ────────────────────────────────
14 *
15 * Elle n'est pas décorative, et sans elle les deux espaces se recopient :
16 *
17 *     RÉSEAU      des liens de personne à personne. Ce que JE publie atteint
18 *                 les gens que je connais. La relation est le sujet.
19 *     COMMUNAUTÉ  des liens de personne à GROUPE. Ce qu'on y dépose reste
20 *                 après nous, appartient au groupe, et sert à quelqu'un qu'on
21 *                 ne connaît pas. Le sujet est le sujet.
22 *
23 * D'où quatre choses qu'un fil ne saura jamais faire, et qui sont ici :
24 *
25 *   Cercles     un endroit qui a des membres, une raison d'être et une durée.
26 *   Événements  une date, un lieu, des inscrits — donc quelque chose qui
27 *               ARRIVE, et pas seulement quelque chose qui se lit.
28 *   Entraide    une QUESTION qui reste ouverte tant que personne n'a répondu,
29 *               et qui se referme sur une réponse retenue. Un fil enterre une
30 *               question en trois heures ; ici elle remonte tant qu'elle vaut.
31 *   Ressources  ce que le groupe a trouvé et gardé — la mémoire commune.
32 *
33 * ── LA PLACE PUBLIQUE ───────────────────────────────────────────────────────
34 *
35 * Le cinquième onglet est le fil OUVERT — tout le monde, sans filtre de
36 * relations. Il est rendu par `renderCommunityFeed` du tronc, celui-là même
37 * qui existait : c'est la même publication, le même moteur, le même serveur.
38 * L'Accueil du Réseau montre ce fil TRIÉ POUR VOUS ; la place publique le
39 * montre tel qu'il est. Deux lectures, une seule source — jamais deux fils à
40 * tenir d'accord.
41 *
42 * ── CE QUE CE MODULE NE TIENT PAS ───────────────────────────────────────────
43 *
44 * Cercles, événements, entraide et ressources vivent SUR L'APPAREIL
45 * (`STATE.communaute`). Le fil et les membres, eux, passent déjà par le
46 * serveur. Tant qu'il n'y a pas de route `/api/communaute/*`, un cercle créé
47 * ici n'est visible que par vous — et le dire fait partie du travail.
48 *
49 * Emprunts : AUCUN nom lu du tronc autrement que par `window`.
50 *
51 * Expose window.CREA_COMMUNAUTE.
52 * ═══════════════════════════════════════════════════════════════════════════ */
53;(function () {
54  'use strict'
55  if (window.CREA_COMMUNAUTE) return
56
57  function S() { return window.STATE || (window.STATE = {}) }
58  function esc(s) {
59    if (typeof window.escHtml === 'function') return window.escHtml(s)
60    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
61      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
62    })
63  }
64  function redessiner() { if (typeof window.render === 'function') window.render() }
65  function garder() { if (typeof window.save === 'function') window.save() }
66  function idNeuf() {
67    if (typeof window.uid === 'function') return window.uid()
68    return 'cm-' + Math.random().toString(36).slice(2) + Date.now().toString(36)
69  }
70  function dire(t, g, s) { if (typeof window.logNotice === 'function') window.logNotice(t, g || 'info', s || 'ℹ️') }
71  function ilYA(t) {
72    if (typeof window.timeAgo === 'function' && t) { try { return window.timeAgo(t) } catch (e) { /* repli */ } }
73    var ms = typeof t === 'number' ? t : Date.parse(t || '')
74    if (!Number.isFinite(ms)) return ''
75    var j = Math.round((Date.now() - ms) / 86400000)
76    return j <= 0 ? "aujourd'hui" : 'il y a ' + j + ' j'
77  }
78  function partage() { return typeof window.sessionPresumee === 'function' ? !!window.sessionPresumee() : false }
79
80  var IC = {
81    cercle: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/>',
82    agenda: '<rect x="4" y="5.5" width="16" height="14" rx="2"/><path d="M4 10h16M9 3.5v4M15 3.5v4"/>',
83    entraide: '<path d="M9.2 9.4a2.9 2.9 0 1 1 3.6 2.8V14"/><circle cx="12.4" cy="17.6" r=".6" fill="currentColor"/><circle cx="12" cy="12" r="9"/>',
84    ressource: '<path d="M5 4.5h9l5 5V19.5H5Z"/><path d="M14 4.5v5h5"/><path d="M8.5 13h7M8.5 16h4.5"/>',
85    place: '<path d="M4 6h16M4 12h16M4 18h10"/>',
86    membres: '<circle cx="9" cy="8" r="3.2"/><path d="M3 19.5a6 6 0 0 1 12 0"/><path d="M16.5 6.2a3.2 3.2 0 0 1 0 6"/><path d="M17.6 14.2a6 6 0 0 1 3.4 5.3"/>',
87    plus: '<path d="M12 5v14M5 12h14"/>',
88    coche: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
89    lieu: '<path d="M12 21s6.5-6 6.5-10.5a6.5 6.5 0 0 0-13 0C5.5 15 12 21 12 21Z"/><circle cx="12" cy="10.4" r="2.4"/>',
90    poubelle: '<path d="M4 7h16M9 7V4.5h6V7M6.5 7l1 12.5h9L17.5 7"/>',
91    lien: '<path d="M10.5 13.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 1 0-5-5l-1.5 1.5"/><path d="M13.5 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 1 0 5 5L12 17"/>',
92  }
93  function ic(n, t, w) {
94    return '<svg viewBox="0 0 24 24" width="' + (t || 16) + '" height="' + (t || 16) + '" fill="none" stroke="currentColor" ' +
95      'stroke-width="' + (w || 1.7) + '" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (IC[n] || IC.cercle) + '</svg>'
96  }
97
98  var ONGLETS = [
99    { id: 'cercles', nom: 'Cercles', ic: 'cercle' },
100    { id: 'evenements', nom: 'Événements', ic: 'agenda' },
101    { id: 'entraide', nom: 'Entraide', ic: 'entraide' },
102    { id: 'ressources', nom: 'Ressources', ic: 'ressource' },
103    { id: 'place', nom: 'Place publique', ic: 'place' },
104    { id: 'membres', nom: 'Membres', ic: 'membres' },
105  ]
106
107  function etat() {
108    var st = S()
109    if (!st.communaute || typeof st.communaute !== 'object') st.communaute = {}
110    var c = st.communaute
111    if (!Array.isArray(c.cercles)) c.cercles = []
112    if (!Array.isArray(c.evenements)) c.evenements = []
113    if (!Array.isArray(c.entraides)) c.entraides = []
114    if (!Array.isArray(c.ressources)) c.ressources = []
115    return c
116  }
117  function moi() {
118    var st = S()
119    return (st.communityProfile && st.communityProfile.name) || st.userName || 'Vous'
120  }
121  function onglet() {
122    var o = etat().onglet
123    return ONGLETS.some(function (x) { return x.id === o }) ? o : 'cercles'
124  }
125
126  /* ═══ CERCLES ═══════════════════════════════════════════════════════════ */
127  function vueCercles() {
128    var c = etat()
129    return `<div class="cm-page">
130      <div class="cm-barre">
131        <p class="cm-page-titre">Cercles (${c.cercles.length})</p>
132        <button class="cm-primaire" data-cm="cercle-creer">${ic('plus', 14)} Ouvrir un cercle</button>
133      </div>
134      <p class="cm-note">Un cercle a des membres, une raison d’être et une durée. C’est ce qui le distingue d’un sujet de conversation : on peut y entrer, en sortir, et il reste quand vous partez.</p>
135      ${c.cercles.length ? `<div class="cm-grille">${c.cercles.map(function (x) {
136        var dedans = (x.membres || []).indexOf(moi()) >= 0
137        return `<article class="cm-carte cm-cercle">
138          <span class="cm-pastille">${ic('cercle', 18)}</span>
139          <div class="cm-cercle-corps">
140            <b>${esc(x.nom)}</b>
141            <span>${esc(x.raison || 'Sans description')}</span>
142            <em>${(x.membres || []).length} membre${(x.membres || []).length > 1 ? 's' : ''} · ouvert ${esc(ilYA(x.createdAt))}</em>
143          </div>
144          <div class="cm-cercle-verbes">
145            <button class="${dedans ? 'cm-plat' : 'cm-primaire'} cm-mini" data-cm="${dedans ? 'cercle-quitter' : 'cercle-rejoindre'}" data-cm-val="${esc(x.id)}">${dedans ? 'Quitter' : 'Rejoindre'}</button>
146            ${x.auteur === moi() ? `<button class="cm-icone" data-cm="cercle-fermer" data-cm-val="${esc(x.id)}" title="Fermer le cercle">${ic('poubelle', 14)}</button>` : ''}
147          </div>
148        </article>`
149      }).join('')}</div>` : vide('Aucun cercle.', 'Le premier cercle donne le ton : nommez-le par ce qu’on y fait, pas par ce dont on y parle.', 'cercle-creer', 'Ouvrir un cercle')}
150    </div>`
151  }
152
153  /* ═══ ÉVÉNEMENTS ════════════════════════════════════════════════════════ */
154  function vueEvenements() {
155    var c = etat()
156    var maintenant = Date.now()
157    var tries = c.evenements.slice().sort(function (a, b) { return (a.quand || 0) - (b.quand || 0) })
158    var aVenir = tries.filter(function (e) { return !e.quand || e.quand >= maintenant })
159    var passes = tries.filter(function (e) { return e.quand && e.quand < maintenant }).reverse()
160    return `<div class="cm-page">
161      <div class="cm-barre">
162        <p class="cm-page-titre">À venir (${aVenir.length})</p>
163        <button class="cm-primaire" data-cm="evenement-creer">${ic('plus', 14)} Proposer</button>
164      </div>
165      ${aVenir.length ? aVenir.map(carteEvenement).join('')
166        : vide('Rien de prévu.', 'Un événement, c’est une date que plusieurs personnes tiennent en même temps — la seule chose qu’un fil ne peut pas produire.', 'evenement-creer', 'Proposer une date')}
167      ${passes.length ? `<p class="cm-page-titre cm-page-titre-second">Passés</p>${passes.slice(0, 6).map(carteEvenement).join('')}` : ''}
168    </div>`
169  }
170  function carteEvenement(e) {
171    var inscrit = (e.inscrits || []).indexOf(moi()) >= 0
172    var quand = e.quand ? new Date(e.quand) : null
173    var passe = e.quand && e.quand < Date.now()
174    return `<article class="cm-carte cm-evenement${passe ? ' cm-passe' : ''}">
175      <div class="cm-date">
176        <b>${quand ? quand.toLocaleDateString('fr-FR', { day: '2-digit' }) : '—'}</b>
177        <span>${quand ? quand.toLocaleDateString('fr-FR', { month: 'short' }) : ''}</span>
178      </div>
179      <div class="cm-evenement-corps">
180        <b>${esc(e.titre)}</b>
181        <span>${quand ? esc(quand.toLocaleString('fr-FR', { weekday: 'long', hour: '2-digit', minute: '2-digit' })) : 'Date à fixer'}</span>
182        <em>${e.distanciel ? 'En ligne' : ic('lieu', 12) + ' ' + esc(e.lieu || 'Lieu à préciser')} · ${(e.inscrits || []).length} inscrit${(e.inscrits || []).length > 1 ? 's' : ''}</em>
183        ${e.detail ? `<p>${esc(e.detail)}</p>` : ''}
184      </div>
185      <div class="cm-evenement-verbes">
186        ${passe ? `<span class="cm-etat">Terminé</span>` : `<button class="${inscrit ? 'cm-plat' : 'cm-primaire'} cm-mini" data-cm="${inscrit ? 'evenement-desinscrire' : 'evenement-inscrire'}" data-cm-val="${esc(e.id)}">${inscrit ? 'Je me retire' : 'Je viens'}</button>`}
187        ${e.auteur === moi() ? `<button class="cm-icone" data-cm="evenement-annuler" data-cm-val="${esc(e.id)}" title="Annuler">${ic('poubelle', 14)}</button>` : ''}
188      </div>
189    </article>`
190  }
191
192  /* ═══ ENTRAIDE ══════════════════════════════════════════════════════════
193   * UNE QUESTION N'EST PAS UNE PUBLICATION. Elle a un état — ouverte ou
194   * résolue — et une réponse RETENUE. C'est ce qui la rend utile à quelqu'un
195   * qui arrivera dans six mois, alors qu'une publication ne sert qu'à ceux qui
196   * passaient là ce jour-là. */
197  function vueEntraide() {
198    var c = etat()
199    var filtre = c.entraideFiltre || 'ouvertes'
200    var liste = c.entraides.slice().sort(function (a, b) { return (b.createdAt || 0) - (a.createdAt || 0) })
201    if (filtre === 'ouvertes') liste = liste.filter(function (q) { return !q.resolue })
202    else if (filtre === 'resolues') liste = liste.filter(function (q) { return q.resolue })
203    else if (filtre === 'miennes') liste = liste.filter(function (q) { return q.auteur === moi() })
204    return `<div class="cm-page">
205      <div class="cm-barre">
206        <p class="cm-page-titre">Entraide</p>
207        <button class="cm-primaire" data-cm="question-poser">${ic('plus', 14)} Poser une question</button>
208      </div>
209      <div class="cm-filtres">
210        ${[['ouvertes', 'Sans réponse'], ['resolues', 'Résolues'], ['miennes', 'Les miennes'], ['toutes', 'Toutes']].map(function (f) {
211          return `<button class="cm-puce${filtre === f[0] ? ' on' : ''}" data-cm="entraide-filtre" data-cm-val="${f[0]}">${f[1]}</button>`
212        }).join('')}
213      </div>
214      ${liste.length ? liste.map(function (q) {
215        var ouverte = c.questionOuverte === q.id
216        var reponses = q.reponses || []
217        return `<article class="cm-carte cm-question${q.resolue ? ' cm-resolue' : ''}">
218          <header>
219            <b>${esc(q.titre)}</b>
220            <span class="cm-etat">${q.resolue ? ic('coche', 12) + ' Résolue' : reponses.length + ' réponse' + (reponses.length > 1 ? 's' : '')}</span>
221          </header>
222          ${q.detail ? `<p class="cm-question-detail">${esc(q.detail)}</p>` : ''}
223          <p class="cm-meta">${esc(q.auteur)} · ${esc(ilYA(q.createdAt))}</p>
224          <footer>
225            <button class="cm-plat cm-mini" data-cm="question-ouvrir" data-cm-val="${esc(q.id)}">${ouverte ? 'Replier' : 'Répondre / lire'}</button>
226            ${q.auteur === moi() ? `<button class="cm-icone" data-cm="question-supprimer" data-cm-val="${esc(q.id)}" title="Supprimer">${ic('poubelle', 14)}</button>` : ''}
227          </footer>
228          ${ouverte ? `<div class="cm-reponses">
229            ${reponses.length ? reponses.map(function (r) {
230              return `<div class="cm-reponse${q.retenue === r.id ? ' cm-retenue' : ''}">
231                <b>${esc(r.qui)}</b><span>${esc(r.texte)}</span><em>${esc(ilYA(r.createdAt))}</em>
232                ${q.auteur === moi() && !q.resolue ? `<button class="cm-plat cm-mini" data-cm="reponse-retenir" data-cm-val="${esc(q.id)}" data-cm-val2="${esc(r.id)}">Retenir</button>` : ''}
233                ${q.retenue === r.id ? `<span class="cm-etat">${ic('coche', 12)} Retenue</span>` : ''}
234              </div>`
235            }).join('') : `<p class="cm-vide-mini">Personne n’a encore répondu.</p>`}
236            <div class="cm-reponse-saisie">
237              <input id="cm-reponse-${esc(q.id)}" placeholder="Votre réponse…" />
238              <button class="cm-primaire cm-mini" data-cm="repondre" data-cm-val="${esc(q.id)}">Répondre</button>
239            </div>
240          </div>` : ''}
241        </article>`
242      }).join('') : vide(filtre === 'ouvertes' ? 'Aucune question en attente.' : 'Rien ici.',
243        'Une question posée ici reste visible tant que personne n’y a répondu. C’est exactement l’inverse d’un fil.',
244        'question-poser', 'Poser une question')}
245    </div>`
246  }
247
248  /* ═══ RESSOURCES ════════════════════════════════════════════════════════ */
249  function vueRessources() {
250    var c = etat()
251    var liste = c.ressources.slice().sort(function (a, b) { return (b.utiles || 0) - (a.utiles || 0) })
252    return `<div class="cm-page">
253      <div class="cm-barre">
254        <p class="cm-page-titre">Ressources (${liste.length})</p>
255        <button class="cm-primaire" data-cm="ressource-ajouter">${ic('plus', 14)} Partager</button>
256      </div>
257      <p class="cm-note">Ce que le groupe a trouvé et gardé. Classé par ce qui a servi, pas par ce qui est récent — une bonne ressource ne vieillit pas en trois jours.</p>
258      ${liste.length ? liste.map(function (r) {
259        var vote = (r.votants || []).indexOf(moi()) >= 0
260        var url = /^https?:\/\//i.test(String(r.lien || '')) ? String(r.lien) : ''
261        return `<article class="cm-carte cm-ressource">
262          <span class="cm-pastille">${ic('ressource', 16)}</span>
263          <div class="cm-ressource-corps">
264            <b>${esc(r.titre)}</b>
265            ${url ? `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${ic('lien', 12)} ${esc(r.lien)}</a>` : ''}
266            ${r.note ? `<span>${esc(r.note)}</span>` : ''}
267            <em>partagé par ${esc(r.qui)} · ${esc(ilYA(r.createdAt))}</em>
268          </div>
269          <div class="cm-ressource-verbes">
270            <button class="cm-plat cm-mini${vote ? ' on' : ''}" data-cm="ressource-utile" data-cm-val="${esc(r.id)}">${ic('coche', 12)} Utile ${r.utiles || 0}</button>
271            ${r.qui === moi() ? `<button class="cm-icone" data-cm="ressource-retirer" data-cm-val="${esc(r.id)}" title="Retirer">${ic('poubelle', 14)}</button>` : ''}
272          </div>
273        </article>`
274      }).join('') : vide('Aucune ressource.', 'La première ressource partagée est celle qu’on aurait aimé trouver soi-même le premier jour.', 'ressource-ajouter', 'Partager une ressource')}
275    </div>`
276  }
277
278  function vide(titre, sous, geste, libelle) {
279    return `<div class="cm-vide">
280      <p class="cm-vide-titre">${esc(titre)}</p>
281      <p class="cm-vide-sous">${esc(sous)}</p>
282      ${geste ? `<button class="cm-primaire" data-cm="${geste}">${esc(libelle || 'Commencer')}</button>` : ''}
283    </div>`
284  }
285
286  /* ═══ LA COQUILLE ═══════════════════════════════════════════════════════
287   * `place` et `membres` sont rendus PAR LE TRONC. Ce sont eux qui parlent au
288   * serveur (publication, médias signés, messages privés, appels) : en
289   * réécrire une version ici aurait fait deux fils à tenir d'accord, et c'est
290   * toujours celui qu'on ne regarde pas qui devient faux. */
291  function rendre() {
292    var st = S()
293    if (partage() && !st.communityRemoteLoaded && !st.communityRemoteLoading && typeof window.loadCommunityRemote === 'function') {
294      setTimeout(function () { window.loadCommunityRemote({ toast: false }) }, 0)
295    }
296    var o = onglet()
297    var corps
298    if (o === 'evenements') corps = vueEvenements()
299    else if (o === 'entraide') corps = vueEntraide()
300    else if (o === 'ressources') corps = vueRessources()
301    else if (o === 'place') {
302      corps = typeof window.renderCommunityFeed === 'function'
303        ? window.renderCommunityFeed(moi(), st.communityProfile || {})
304        : `<div class="cm-vide"><p class="cm-vide-titre">La place publique est indisponible.</p></div>`
305    } else if (o === 'membres') {
306      corps = typeof window.renderCommunityMembers === 'function'
307        ? window.renderCommunityMembers()
308        : `<div class="cm-vide"><p class="cm-vide-titre">La liste des membres est indisponible.</p></div>`
309    } else corps = vueCercles()
310
311    var entete = typeof window._espaceMiniHeader === 'function'
312      ? window._espaceMiniHeader('Communauté', '', '')
313      : `<div class="cm-entete-repli">Communauté</div>`
314    /* PAS DE BARRE DE QUESTIONS ICI NON PLUS — 188 px, 26 % de l'écran, posée
315     * par-dessus ce qu'on est venu lire. Même retrait, même raison mesurée que
316     * Découvrir, Finance, le Directeur financier, le Marché et le Réseau. */
317    var modale = (st.communityChatWith && typeof window.renderCommunityChatModal === 'function')
318      ? window.renderCommunityChatModal() : ''
319    return `<main class="cm-espace flex flex-1 flex-col overflow-hidden min-w-0" style="background:var(--bg-primary)">
320      ${entete}
321      <nav class="cm-onglets">
322        ${ONGLETS.map(function (x) {
323          var n = x.id === 'cercles' ? etat().cercles.length
324            : x.id === 'evenements' ? etat().evenements.filter(function (e) { return !e.quand || e.quand >= Date.now() }).length
325              : x.id === 'entraide' ? etat().entraides.filter(function (q) { return !q.resolue }).length
326                : x.id === 'ressources' ? etat().ressources.length
327                  : 0
328          return `<button class="cm-onglet${x.id === o ? ' on' : ''}" data-cm="onglet" data-cm-val="${x.id}">${ic(x.ic, 17)}<span>${x.nom}</span>${n ? `<b>${n}</b>` : ''}</button>`
329        }).join('')}
330        <button class="cm-onglet cm-onglet-sortie" data-cm="ouvrir-mode" data-cm-val="network" title="Retour au Réseau">Réseau ↗</button>
331      </nav>
332      ${!partage() && (o === 'place' || o === 'membres') ? `<p class="cm-avis">Espace local : connectez-vous pour publier et voir les autres membres.</p>` : ''}
333      <div class="cm-defilement flex-1 overflow-y-auto min-h-0">${corps}</div>
334      ${modale}
335    </main>`
336  }
337
338  /* ═══ LES GESTES ════════════════════════════════════════════════════════ */
339  function cible(e) {
340    var n = e.target
341    while (n && n !== document) {
342      if (n.getAttribute && n.getAttribute('data-cm')) return n
343      n = n.parentNode
344    }
345    return null
346  }
347  async function demander(titre, exemple, multiligne) {
348    if (typeof window.promptModal !== 'function') return null
349    return window.promptModal(titre, exemple, '', 'Valider', { multiline: !!multiligne })
350  }
351
352  async function agir(geste, val, val2) {
353    var c = etat()
354    switch (geste) {
355      case 'onglet': c.onglet = val; garder(); redessiner(); return
356      case 'ouvrir-mode': if (typeof window.openModeTab === 'function') window.openModeTab(val); return
357
358      /* ── CERCLES ────────────────────────────────────────────────────── */
359      case 'cercle-creer': {
360        var nom = await demander('Nom du cercle', 'ex. Comptables d’Île-de-France')
361        if (!nom || !String(nom).trim()) return
362        var raison = await demander('À quoi sert ce cercle ?', 'ce qu’on y fait, en une phrase')
363        c.cercles.unshift({
364          id: idNeuf(), nom: String(nom).trim(), raison: String(raison || '').trim(),
365          auteur: moi(), membres: [moi()], createdAt: Date.now(),
366        })
367        garder(); redessiner(); return
368      }
369      case 'cercle-rejoindre': {
370        var x1 = c.cercles.find(function (x) { return x.id === val }); if (!x1) return
371        x1.membres = x1.membres || []
372        if (x1.membres.indexOf(moi()) < 0) x1.membres.push(moi())
373        garder(); redessiner(); return
374      }
375      case 'cercle-quitter': {
376        var x2 = c.cercles.find(function (x) { return x.id === val }); if (!x2) return
377        x2.membres = (x2.membres || []).filter(function (m) { return m !== moi() })
378        garder(); redessiner(); return
379      }
380      case 'cercle-fermer': {
381        var x3 = c.cercles.find(function (x) { return x.id === val }); if (!x3) return
382        var ok = typeof window.confirmModal === 'function'
383          ? await window.confirmModal('Fermer le cercle « ' + esc(x3.nom) + ' » ?', { confirmLabel: 'Fermer', danger: true })
384          : true
385        if (!ok) return
386        c.cercles = c.cercles.filter(function (x) { return x.id !== val })
387        garder(); redessiner(); return
388      }
389
390      /* ── ÉVÉNEMENTS ─────────────────────────────────────────────────── */
391      case 'evenement-creer': {
392        var titre = await demander('Titre de l’événement', 'ex. Permanence entraide — jeudi soir')
393        if (!titre || !String(titre).trim()) return
394        var date = await demander('Quand ? (JJ/MM/AAAA HH:MM)', 'ex. 12/09/2026 18:30')
395        var lieu = await demander('Où ? (vide = en ligne)', 'ex. Cergy, salle 2')
396        c.evenements.unshift({
397          id: idNeuf(), titre: String(titre).trim(), quand: lireDate(date),
398          lieu: String(lieu || '').trim(), distanciel: !String(lieu || '').trim(),
399          auteur: moi(), inscrits: [moi()], createdAt: Date.now(),
400        })
401        garder(); redessiner(); return
402      }
403      case 'evenement-inscrire': {
404        var e1 = c.evenements.find(function (x) { return x.id === val }); if (!e1) return
405        e1.inscrits = e1.inscrits || []
406        if (e1.inscrits.indexOf(moi()) < 0) e1.inscrits.push(moi())
407        garder(); redessiner(); return
408      }
409      case 'evenement-desinscrire': {
410        var e2 = c.evenements.find(function (x) { return x.id === val }); if (!e2) return
411        e2.inscrits = (e2.inscrits || []).filter(function (m) { return m !== moi() })
412        garder(); redessiner(); return
413      }
414      case 'evenement-annuler':
415        c.evenements = c.evenements.filter(function (x) { return x.id !== val })
416        garder(); redessiner(); return
417
418      /* ── ENTRAIDE ───────────────────────────────────────────────────── */
419      case 'entraide-filtre': c.entraideFiltre = val; redessiner(); return
420      case 'question-poser': {
421        var qt = await demander('Votre question', 'ex. Comment justifier un écart de caisse ?')
422        if (!qt || !String(qt).trim()) return
423        var qd = await demander('Le contexte (facultatif)', 'ce que vous avez déjà essayé', true)
424        c.entraides.unshift({
425          id: idNeuf(), titre: String(qt).trim(), detail: String(qd || '').trim(),
426          auteur: moi(), reponses: [], resolue: false, createdAt: Date.now(),
427        })
428        c.questionOuverte = c.entraides[0].id
429        garder(); redessiner(); return
430      }
431      case 'question-ouvrir':
432        c.questionOuverte = c.questionOuverte === val ? '' : val
433        redessiner()
434        setTimeout(function () { var el = document.getElementById('cm-reponse-' + val); if (el) el.focus() }, 0)
435        return
436      case 'question-supprimer':
437        c.entraides = c.entraides.filter(function (x) { return x.id !== val })
438        garder(); redessiner(); return
439      case 'repondre': {
440        var champ = document.getElementById('cm-reponse-' + val)
441        var texte = champ && champ.value ? champ.value.trim() : ''
442        if (!texte) return
443        var q1 = c.entraides.find(function (x) { return x.id === val }); if (!q1) return
444        q1.reponses = q1.reponses || []
445        q1.reponses.push({ id: idNeuf(), qui: moi(), texte: texte, createdAt: Date.now() })
446        if (champ) champ.value = ''
447        garder(); redessiner(); return
448      }
449      case 'reponse-retenir': {
450        var q2 = c.entraides.find(function (x) { return x.id === val }); if (!q2) return
451        q2.retenue = val2
452        q2.resolue = true
453        dire('Question refermée sur une réponse. Elle restera lisible.', 'success', '✅')
454        garder(); redessiner(); return
455      }
456
457      /* ── RESSOURCES ─────────────────────────────────────────────────── */
458      case 'ressource-ajouter': {
459        var rt = await demander('Titre de la ressource', 'ex. Le guide du plan comptable, en clair')
460        if (!rt || !String(rt).trim()) return
461        var rl = await demander('Lien (facultatif)', 'https://…')
462        var rn = await demander('Pourquoi c’est utile ? (facultatif)', 'en une phrase')
463        c.ressources.unshift({
464          id: idNeuf(), titre: String(rt).trim(), lien: String(rl || '').trim(),
465          note: String(rn || '').trim(), qui: moi(), utiles: 0, votants: [], createdAt: Date.now(),
466        })
467        garder(); redessiner(); return
468      }
469      case 'ressource-utile': {
470        var r1 = c.ressources.find(function (x) { return x.id === val }); if (!r1) return
471        r1.votants = r1.votants || []
472        var i = r1.votants.indexOf(moi())
473        if (i >= 0) { r1.votants.splice(i, 1); r1.utiles = Math.max(0, (r1.utiles || 1) - 1) }
474        else { r1.votants.push(moi()); r1.utiles = (r1.utiles || 0) + 1 }
475        garder(); redessiner(); return
476      }
477      case 'ressource-retirer':
478        c.ressources = c.ressources.filter(function (x) { return x.id !== val })
479        garder(); redessiner(); return
480    }
481  }
482
483  /* « 12/09/2026 18:30 » — LU, PAS DEVINÉ. `new Date('12/09/2026')` lit un mois
484   * de décembre en anglais : la même chaîne donnerait septembre ici et décembre
485   * ailleurs, et personne ne le verrait avant le jour de l'événement. */
486  function lireDate(texte) {
487    var s = String(texte || '').trim()
488    if (!s) return 0
489    var m = s.match(/^(\d{1,2})[/.\-](\d{1,2})[/.\-](\d{4})(?:\s+(\d{1,2})[:h](\d{2}))?$/)
490    if (!m) { var t = Date.parse(s); return Number.isFinite(t) ? t : 0 }
491    var d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]), Number(m[4] || 9), Number(m[5] || 0))
492    return d.getTime()
493  }
494
495  function brancher() {
496    if (brancher.fait) return
497    brancher.fait = true
498    document.addEventListener('click', function (e) {
499      var el = cible(e)
500      if (!el) return
501      e.preventDefault()
502      Promise.resolve(agir(el.getAttribute('data-cm'), el.getAttribute('data-cm-val') || '', el.getAttribute('data-cm-val2') || ''))
503        .catch(function (err) { dire('Geste impossible : ' + esc(err && err.message), 'error', '⚠️') })
504    })
505    document.addEventListener('keydown', function (e) {
506      if (e.key !== 'Enter' || e.shiftKey) return
507      var t = e.target
508      if (!t || !t.id || t.id.indexOf('cm-reponse-') !== 0) return
509      e.preventDefault()
510      agir('repondre', t.id.slice('cm-reponse-'.length), '')
511    })
512  }
513
514  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', brancher)
515  else brancher()
516
517  window.CREA_COMMUNAUTE = {
518    rendre: rendre,
519    onglets: ONGLETS,
520    ouvrirOnglet: function (id) { etat().onglet = id; garder(); redessiner() },
521    /* Ce que le Discover des Espaces vient chercher : les prochaines dates et
522     * les questions qui attendent encore quelqu'un. */
523    prochains: function (n) {
524      return etat().evenements
525        .filter(function (e) { return !e.quand || e.quand >= Date.now() })
526        .sort(function (a, b) { return (a.quand || 0) - (b.quand || 0) })
527        .slice(0, n || 3)
528    },
529    questionsOuvertes: function (n) {
530      return etat().entraides.filter(function (q) { return !q.resolue }).slice(0, n || 3)
531    },
532  }
533})()

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.