discovery-media-player 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,319 @@
1
+ // Mode « Présenter » : sessions de présentation live (page synchronisée). Table doc_presentations
2
+ // (cf. migration v12324) — écriture service role only. Le présentateur détient un control_token ; on en
3
+ // stocke le HASH (sha256) → l'audience peut lire la ligne (Realtime) sans pouvoir piloter.
4
+ const crypto = require("crypto");
5
+ // Base de données via le contexte injecté (cf. _player-context.js) — aucune adhérence au studio.
6
+ // ⚠️ Le contexte est REÇU, pas construit. Ce module ne doit pas savoir d'où il vient : c'est ce
7
+ // qui lui permettra de partir dans le dépôt du player sans emporter le studio avec lui.
8
+ let PLAYER = null;
9
+ function init(ctx) { PLAYER = ctx; }
10
+
11
+
12
+ const enc = encodeURIComponent;
13
+ const sha = (s) => crypto.createHash("sha256").update(String(s || "")).digest("hex");
14
+ const newToken = (n) => crypto.randomBytes(n).toString("base64url");
15
+ // Comparaison CONSTANTE-TEMPS des hashes de jeton (pas de fuite d'information par timing).
16
+ const sameHash = (a, b) => {
17
+ const ba = Buffer.from(String(a || ""), "utf8"); const bb = Buffer.from(String(b || ""), "utf8");
18
+ return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
19
+ };
20
+ // Le jeton fourni correspond-il au hash stocké ?
21
+ const tokenMatches = (token, storedHash) => !!(token && storedHash && sameHash(sha(token), storedHash));
22
+
23
+ // Crée une session → renvoie { slug (public, dans le lien), control (secret présentateur) }.
24
+ // owner = { id, email, name, avatar } (membre authentifié). La CLÉ de propriété est l'EMAIL (issu du JWT
25
+ // vérifié) → permet reprise / liste / transfert, y compris vers un membre choisi par email.
26
+ const lc = (s) => String(s || "").trim().toLowerCase();
27
+ async function createPresentation({ docId, fileUrl, fileName, docTitle, presenterName, owner }) {
28
+ if (!fileUrl) throw Object.assign(new Error("doc invalide"), { statusCode: 400 });
29
+ const slug = newToken(9); // ~12 chars URL-safe
30
+ const control = newToken(18); // secret pilotage
31
+ const o = owner && typeof owner === "object" ? owner : {};
32
+ const row = {
33
+ slug, control_hash: sha(control), doc_id: docId ? String(docId) : null, file_url: String(fileUrl),
34
+ file_name: fileName || null, doc_title: docTitle || null, presenter_name: (presenterName || "").trim() || null,
35
+ owner_user_id: o.id ? String(o.id) : null, owner_email: lc(o.email) || null, owner_name: (o.name || "").slice(0, 120) || null, owner_avatar: (o.avatar || "").slice(0, 600) || null,
36
+ current_page: 1, active: true, last_seen: new Date().toISOString(),
37
+ };
38
+ await PLAYER.db.request("doc_presentations", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
39
+ return { slug, control };
40
+ }
41
+
42
+ // Reprise : le propriétaire (membre authentifié, email issu du JWT) re-génère un control_token frais →
43
+ // il reprend la main depuis n'importe quel onglet/navigateur/appareil ; l'ancien control est invalidé.
44
+ async function reclaimPresentation(slug, email) {
45
+ if (!slug || !lc(email)) return { ok: false, status: 400 };
46
+ const row = await getPresentation(slug);
47
+ if (!row) return { ok: false, status: 404 };
48
+ if (!row.owner_email || row.owner_email !== lc(email)) return { ok: false, status: 403 };
49
+ const control = newToken(18);
50
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { control_hash: sha(control), active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
51
+ return { ok: true, slug, control, page: row.current_page || 1, fileUrl: row.file_url, fileName: row.file_name, docTitle: row.doc_title, docId: row.doc_id };
52
+ }
53
+
54
+ // Heartbeat présentateur (control requis) : rafraîchit last_seen → distingue une présentation vivante d'une orpheline.
55
+ async function touchPresentation(slug, control) {
56
+ if (!slug) return { ok: false, status: 400 };
57
+ const row = await getPresentation(slug);
58
+ if (!row) return { ok: false, status: 404 };
59
+ if (!tokenMatches(control, row.control_hash)) return { ok: false, status: 403 };
60
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { last_seen: new Date().toISOString() } });
61
+ return { ok: true };
62
+ }
63
+
64
+ // Liste des présentations en cours (membre authentifié). Auto-purge : une présentation active dont le
65
+ // dernier heartbeat remonte à > STALE_MS (présentateur parti sans clôturer) est marquée inactive.
66
+ const STALE_MS = 3 * 60 * 1000;
67
+ async function listActivePresentations(email) {
68
+ const me = lc(email);
69
+ const rows = await PLAYER.db.request("doc_presentations?active=eq.true&select=slug,doc_id,file_url,file_name,doc_title,presenter_name,owner_email,owner_name,owner_avatar,current_page,last_seen,created_at,updated_at&order=updated_at.desc&limit=100");
70
+ const list = Array.isArray(rows) ? rows : [];
71
+ const now = Date.now();
72
+ const stale = list.filter((r) => now - new Date(r.last_seen || r.updated_at || 0).getTime() > STALE_MS).map((r) => r.slug);
73
+ if (stale.length) {
74
+ await PLAYER.db.request(`doc_presentations?slug=in.(${stale.map((s) => enc(s)).join(",")})`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { active: false, updated_at: new Date().toISOString() } }).catch(() => {});
75
+ }
76
+ const live = list.filter((r) => !stale.includes(r.slug));
77
+ return live.map((r) => ({ slug: r.slug, docId: r.doc_id, fileUrl: r.file_url, fileName: r.file_name, docTitle: r.doc_title, presenterName: r.presenter_name, ownerName: r.owner_name, ownerAvatar: r.owner_avatar, currentPage: r.current_page || 1, mine: !!(me && r.owner_email && r.owner_email === me), updatedAt: r.updated_at }));
78
+ }
79
+
80
+ // Clôture sans control_token → utilisée par le panneau « Présentations en direct » pour terminer à distance
81
+ // une présentation dont on a perdu l'onglet. Autorisée au PROPRIÉTAIRE (email du JWT) OU à un ADMIN (modération).
82
+ async function endPresentationByOwner(slug, email, isAdmin) {
83
+ if (!slug || (!lc(email) && !isAdmin)) return { ok: false, status: 400 };
84
+ const row = await getPresentation(slug);
85
+ if (!row) return { ok: false, status: 404 };
86
+ if (!isAdmin && (!row.owner_email || row.owner_email !== lc(email))) return { ok: false, status: 403 };
87
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { active: false, updated_at: new Date().toISOString() } });
88
+ return { ok: true };
89
+ }
90
+
91
+ // Transfert de contrôle : le propriétaire actuel (JWT) désigne un nouveau membre propriétaire (par email).
92
+ // Le nouvel owner reprendra la main via reclaimPresentation (control frais) → l'ancien control reste valide
93
+ // jusque-là (l'ancien présentateur cesse volontairement de piloter).
94
+ async function handoverPresentation(slug, currentEmail, newOwner) {
95
+ const o = newOwner && typeof newOwner === "object" ? newOwner : {};
96
+ if (!slug || !lc(currentEmail) || !lc(o.email)) return { ok: false, status: 400 };
97
+ const row = await getPresentation(slug);
98
+ if (!row) return { ok: false, status: 404 };
99
+ if (!row.owner_email || row.owner_email !== lc(currentEmail)) return { ok: false, status: 403 };
100
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { owner_user_id: o.id ? String(o.id) : null, owner_email: lc(o.email), owner_name: (o.name || "").slice(0, 120) || null, owner_avatar: (o.avatar || "").slice(0, 600) || null, updated_at: new Date().toISOString() } });
101
+ return { ok: true, slug };
102
+ }
103
+
104
+ async function getPresentation(slug) {
105
+ const rows = await PLAYER.db.request(`doc_presentations?slug=eq.${enc(String(slug || ""))}&select=*&limit=1`);
106
+ return Array.isArray(rows) && rows[0] ? rows[0] : null;
107
+ }
108
+
109
+ // Pilotage : change la page courante (présentateur uniquement, via control_token).
110
+ async function setPage(slug, control, page) {
111
+ const row = await getPresentation(slug);
112
+ if (!row) return { ok: false, status: 404 };
113
+ if (!tokenMatches(control, row.control_hash)) return { ok: false, status: 403 };
114
+ const p = Math.max(1, Math.trunc(Number(page) || 1));
115
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { current_page: p, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
116
+ return { ok: true };
117
+ }
118
+
119
+ // Fin de présentation → l'audience voit « terminée ».
120
+ async function endPresentation(slug, control) {
121
+ const row = await getPresentation(slug);
122
+ if (!row) return { ok: false, status: 404 };
123
+ if (!tokenMatches(control, row.control_hash)) return { ok: false, status: 403 };
124
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { active: false, updated_at: new Date().toISOString() } });
125
+ return { ok: true };
126
+ }
127
+
128
+ // Chat de présentation (historisé) : ajout d'un message (+ réponse citée) + liste de l'historique.
129
+ // Pièce jointe : URL d'upload SIGNÉE (service role) → le client PUT directement dans le bucket. Type/taille
130
+ // validés par le bucket (image/*+pdf, ≤10 Mo). L'URL publique finale est renvoyée pour attacher au message.
131
+ const ATT_KINDS = { "image/png": "image", "image/jpeg": "image", "image/webp": "image", "image/gif": "image", "application/pdf": "pdf" };
132
+ async function createUploadUrl(slug, name, type) {
133
+ const base = (process.env.SUPABASE_URL || "").replace(/\/+$/, ""); const KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
134
+ const kind = ATT_KINDS[String(type || "").toLowerCase()];
135
+ if (!kind || !slug || !base || !KEY) return { ok: false, status: 400 };
136
+ const safe = (String(name || "fichier").replace(/[^a-zA-Z0-9._-]/g, "_").slice(-60)) || "fichier";
137
+ const path = `${String(slug).replace(/[^a-zA-Z0-9._-]/g, "")}/${Date.now()}-${crypto.randomBytes(4).toString("hex")}-${safe}`;
138
+ const r = await fetch(`${base}/storage/v1/object/upload/sign/present-attachments/${path}`, { method: "POST", headers: { apikey: KEY, Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" }, body: "{}" });
139
+ if (!r.ok) return { ok: false, status: 502 };
140
+ const d = await r.json().catch(() => null); const url = d && d.url; if (!url) return { ok: false, status: 502 };
141
+ const token = (String(url).split("token=")[1] || "").split("&")[0];
142
+ return { ok: true, path, token, kind, publicUrl: `${base}/storage/v1/object/public/present-attachments/${path}` };
143
+ }
144
+
145
+ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment }) {
146
+ const b = String(body || "").trim().slice(0, 2000);
147
+ const base = (process.env.SUPABASE_URL || "").replace(/\/+$/, "");
148
+ let att = null;
149
+ if (attachment && typeof attachment === "object" && attachment.url && String(attachment.url).startsWith(base + "/storage/v1/object/public/present-attachments/")) {
150
+ att = { url: String(attachment.url).slice(0, 600), name: String(attachment.name || "").slice(0, 120), type: String(attachment.type || "").slice(0, 60), kind: attachment.kind === "pdf" ? "pdf" : "image" };
151
+ }
152
+ if ((!b && !att) || !slug) return { ok: false, status: 400 };
153
+ const rt = Number.isFinite(+replyTo) ? Math.trunc(+replyTo) : null;
154
+ const row = {
155
+ slug: String(slug), author_name: (name || "").trim().slice(0, 80) || null,
156
+ author_email: (email || "").trim().toLowerCase().slice(0, 160) || null,
157
+ author_avatar: (avatar || "").slice(0, 600) || null,
158
+ is_presenter: !!isPresenter, is_member: !!isMember, body: b, attachment: att,
159
+ author_hash: authorToken ? sha(authorToken) : null,
160
+ reply_to: rt, reply_name: rt ? ((replyName || "").slice(0, 80) || null) : null, reply_text: rt ? ((replyText || "").slice(0, 140) || null) : null,
161
+ };
162
+ await PLAYER.db.request("doc_presentation_messages", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
163
+ return { ok: true };
164
+ }
165
+
166
+ async function listMessages(slug) {
167
+ const rows = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(String(slug || ""))}&select=id,author_name,author_email,author_avatar,is_presenter,is_member,body,attachment,reactions,reply_to,reply_name,reply_text,deleted,edited,created_at&order=created_at.asc&limit=300`);
168
+ return Array.isArray(rows) ? rows : [];
169
+ }
170
+
171
+ // Éditer son message (jeton d'auteur requis). Ne touche pas aux messages supprimés.
172
+ async function editMessage(slug, msgId, authorToken, body) {
173
+ const id = Math.trunc(+msgId); const b = String(body || "").trim().slice(0, 2000);
174
+ if (!id || !b || !authorToken) return { ok: false, status: 400 };
175
+ const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=author_hash,deleted&limit=1`);
176
+ const m = Array.isArray(rows) && rows[0]; if (!m || m.deleted) return { ok: false, status: 404 };
177
+ if (!m.author_hash || m.author_hash !== sha(authorToken)) return { ok: false, status: 403 };
178
+ await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { body: b, edited: true } });
179
+ return { ok: true };
180
+ }
181
+
182
+ // Supprimer (soft) : l'auteur (jeton) OU le présentateur (control_token de la présentation).
183
+ async function deleteMessage(slug, msgId, { authorToken, control }) {
184
+ const id = Math.trunc(+msgId);
185
+ if (!id) return { ok: false, status: 400 };
186
+ const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=author_hash&limit=1`);
187
+ const m = Array.isArray(rows) && rows[0]; if (!m) return { ok: false, status: 404 };
188
+ const byAuthor = tokenMatches(authorToken, m.author_hash);
189
+ let byPresenter = false;
190
+ if (!byAuthor && control) { const pres = await getPresentation(slug); byPresenter = !!(pres && tokenMatches(control, pres.control_hash)); }
191
+ if (!byAuthor && !byPresenter) return { ok: false, status: 403 };
192
+ await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { deleted: true, body: "", reactions: {}, attachment: null } });
193
+ return { ok: true };
194
+ }
195
+
196
+ // Verrouiller / déverrouiller le chat (présentateur uniquement).
197
+ async function setChatLock(slug, control, locked) {
198
+ const pres = await getPresentation(slug);
199
+ if (!pres) return { ok: false, status: 404 };
200
+ if (!tokenMatches(control, pres.control_hash)) return { ok: false, status: 403 };
201
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(String(slug || ""))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { chat_locked: !!locked } });
202
+ return { ok: true };
203
+ }
204
+
205
+ // Réaction emoji (toggle) : le participant (identifié par email ou nom) ajoute/retire un emoji sur un message.
206
+ async function toggleReaction(slug, msgId, emoji, reactor) {
207
+ const id = Math.trunc(+msgId); const e = String(emoji || "").slice(0, 8); const who = String(reactor || "").slice(0, 160).toLowerCase();
208
+ if (!id || !e || !who) return { ok: false, status: 400 };
209
+ const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=reactions&limit=1`);
210
+ const cur = (Array.isArray(rows) && rows[0] && rows[0].reactions && typeof rows[0].reactions === "object") ? rows[0].reactions : {};
211
+ const arr = Array.isArray(cur[e]) ? cur[e] : [];
212
+ const i = arr.indexOf(who);
213
+ if (i >= 0) arr.splice(i, 1); else arr.push(who);
214
+ if (arr.length) cur[e] = arr; else delete cur[e];
215
+ await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { reactions: cur } });
216
+ return { ok: true };
217
+ }
218
+
219
+ // ── Statistiques de présentation (assistance) ────────────────────────────────────────────────────────────
220
+ // Heartbeat d'un participant : upsert de sa ligne d'assistance. On accumule le temps de présence (intervalles
221
+ // < 60 s → un aller-retour ne gonfle pas total_ms) et l'ensemble des pages vues (page courante de la présentation).
222
+ const ATTEND_MAX_GAP_MS = 60 * 1000;
223
+ async function recordAttendance(slug, { key, name, email, avatar, isMember, isPresenter }) {
224
+ if (!slug || !key) return { ok: false, status: 400 };
225
+ const pres = await getPresentation(slug);
226
+ if (!pres) return { ok: false, status: 404 };
227
+ const page = Math.max(1, Math.trunc(Number(pres.current_page) || 1));
228
+ const now = Date.now();
229
+ const rows = await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}&select=*&limit=1`);
230
+ const cur = Array.isArray(rows) && rows[0];
231
+ if (!cur) {
232
+ const row = { slug: String(slug), attendee_key: String(key).slice(0, 200), name: (name || "").slice(0, 120) || null, email: lc(email) || null, avatar: (avatar || "").slice(0, 600) || null, is_member: !!isMember, is_presenter: !!isPresenter, first_seen: new Date(now).toISOString(), last_seen: new Date(now).toISOString(), total_ms: 0, pages: [page] };
233
+ await PLAYER.db.request("doc_presentation_attendees", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
234
+ return { ok: true };
235
+ }
236
+ const gap = now - new Date(cur.last_seen || now).getTime();
237
+ const addMs = gap > 0 && gap <= ATTEND_MAX_GAP_MS ? gap : 0;
238
+ const pages = Array.isArray(cur.pages) ? cur.pages.slice() : [];
239
+ if (!pages.includes(page)) pages.push(page);
240
+ await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null } });
241
+ return { ok: true };
242
+ }
243
+
244
+ // Détail d'une présentation : entête + participants + nombre de messages par participant.
245
+ async function presentationStats(slug) {
246
+ if (!slug) return { ok: false, status: 400 };
247
+ const pres = await getPresentation(slug);
248
+ if (!pres) return { ok: false, status: 404 };
249
+ const [attRows, msgRows] = await Promise.all([
250
+ PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&select=*&order=first_seen.asc&limit=500`),
251
+ PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}&deleted=eq.false&select=author_email,author_name&limit=1000`),
252
+ ]);
253
+ const msgs = Array.isArray(msgRows) ? msgRows : [];
254
+ const msgByKey = {};
255
+ msgs.forEach((m) => { const k = lc(m.author_email) || ("name:" + (m.author_name || "")); msgByKey[k] = (msgByKey[k] || 0) + 1; });
256
+ const attendees = (Array.isArray(attRows) ? attRows : []).map((a) => {
257
+ const k = lc(a.email) || ("name:" + (a.name || ""));
258
+ const pages = Array.isArray(a.pages) ? a.pages : [];
259
+ return { name: a.name, email: a.email, avatar: a.avatar, isMember: !!a.is_member, isPresenter: !!a.is_presenter, firstSeen: a.first_seen, lastSeen: a.last_seen, totalMs: Number(a.total_ms || 0), pages, pagesCount: pages.length, msgCount: msgByKey[k] || 0 };
260
+ });
261
+ const viewers = attendees.filter((a) => !a.isPresenter);
262
+ const start = new Date(pres.created_at || 0).getTime();
263
+ const lastActivity = Math.max(new Date(pres.updated_at || 0).getTime(), ...attendees.map((a) => new Date(a.lastSeen || 0).getTime()), start);
264
+ return {
265
+ ok: true,
266
+ presentation: { slug: pres.slug, docId: pres.doc_id, docTitle: pres.doc_title, fileName: pres.file_name, presenterName: pres.presenter_name, ownerName: pres.owner_name, currentPage: pres.current_page || 1, active: !!pres.active, createdAt: pres.created_at, endedAt: pres.active ? null : pres.updated_at, durationMs: Math.max(0, lastActivity - start) },
267
+ summary: { total: viewers.length, members: viewers.filter((a) => a.isMember).length, externals: viewers.filter((a) => !a.isMember).length, messages: msgs.length, pagesReached: Math.max(1, pres.current_page || 1) },
268
+ attendees,
269
+ };
270
+ }
271
+
272
+ // Changer le document présenté SANS interrompre la session (même slug → chat/présence/participants conservés).
273
+ // Autorisé au propriétaire (email du JWT) OU à un admin. L'URL est validée en amont (isAllowedStorageUrl, doc.js).
274
+ async function switchPresentationDoc(slug, email, isAdmin, { fileUrl, fileName, docTitle, docId }) {
275
+ if (!slug || !fileUrl) return { ok: false, status: 400 };
276
+ const row = await getPresentation(slug);
277
+ if (!row) return { ok: false, status: 404 };
278
+ if (!isAdmin && (!row.owner_email || row.owner_email !== lc(email))) return { ok: false, status: 403 };
279
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: {
280
+ file_url: String(fileUrl), file_name: fileName || null, doc_title: docTitle || null, doc_id: docId ? String(docId) : null,
281
+ content: null, current_page: 1, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString(),
282
+ } });
283
+ return { ok: true };
284
+ }
285
+
286
+ // Contenu courant de la présentation : bascule PDF ↔ carte live (Leaflet). Réservé propriétaire/admin.
287
+ // content = { kind:'map', center:[lat,lng], zoom, marker:[lat,lng]|null, label } ; kind 'pdf'/null → le document.
288
+ // Contrat de contenu partagé avec le navigateur — UN seul exemplaire, testé sous player/src/.
289
+ // Il traverse trois frontières (présentateur → serveur → audience) : deux implémentations
290
+ // finissaient par diverger, et une audience qui ne voit pas la bonne carte n'émet aucune erreur.
291
+ const { sanitizeContent } = require("./shared.generated.js");
292
+
293
+ async function setPresentationContent(slug, email, isAdmin, content) {
294
+ if (!slug) return { ok: false, status: 400 };
295
+ const row = await getPresentation(slug);
296
+ if (!row) return { ok: false, status: 404 };
297
+ if (!isAdmin && (!row.owner_email || row.owner_email !== lc(email))) return { ok: false, status: 403 };
298
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { content: sanitizeContent(content), active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
299
+ return { ok: true };
300
+ }
301
+
302
+ // Historique des présentations d'un document (pour l'onglet Suivi) : la plus récente d'abord, avec le nb de participants.
303
+ async function listPresentationsForDoc(docId) {
304
+ if (!docId) return [];
305
+ const rows = await PLAYER.db.request(`doc_presentations?doc_id=eq.${enc(String(docId))}&select=slug,presenter_name,owner_name,current_page,active,created_at,updated_at&order=created_at.desc&limit=50`);
306
+ const list = Array.isArray(rows) ? rows : [];
307
+ // UNE requête groupée (in.(…)) au lieu d'une par présentation (N+1, jusqu'à 50) ; agrégation en mémoire.
308
+ const counts = {};
309
+ if (list.length) {
310
+ try {
311
+ const slugs = list.map((p) => enc(p.slug)).join(",");
312
+ const att = await PLAYER.db.request(`doc_presentation_attendees?slug=in.(${slugs})&is_presenter=eq.false&select=slug&limit=5000`);
313
+ for (const a of Array.isArray(att) ? att : []) counts[a.slug] = (counts[a.slug] || 0) + 1;
314
+ } catch { /* best-effort : compteurs à 0 */ }
315
+ }
316
+ return list.map((p) => ({ slug: p.slug, presenterName: p.presenter_name, ownerName: p.owner_name, currentPage: p.current_page || 1, active: !!p.active, createdAt: p.created_at, endedAt: p.active ? null : p.updated_at, attendees: counts[p.slug] || 0 }));
317
+ }
318
+
319
+ module.exports = { init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
@@ -0,0 +1,93 @@
1
+ // GÉNÉRÉ par `npm run build:player` — NE PAS ÉDITER À LA MAIN.
2
+ // Sources : src/presentation-content.ts
3
+ //
4
+ // Contrats partagés entre le navigateur et les fonctions serverless. Un seul exemplaire :
5
+ // deux implémentations d'un même contrat finissent toujours par diverger en silence.
6
+ "use strict";
7
+ var __defProp = Object.defineProperty;
8
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
9
+ var __getOwnPropNames = Object.getOwnPropertyNames;
10
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
24
+
25
+ // src/presentation-content.ts
26
+ var presentation_content_exports = {};
27
+ __export(presentation_content_exports, {
28
+ DEFAULT_CENTER: () => DEFAULT_CENTER,
29
+ DEFAULT_ZOOM: () => DEFAULT_ZOOM,
30
+ MAP_TYPES: () => MAP_TYPES,
31
+ cycleMapType: () => cycleMapType,
32
+ initialMapContent: () => initialMapContent,
33
+ mapTypeLabel: () => mapTypeLabel,
34
+ sanitizeContent: () => sanitizeContent
35
+ });
36
+ module.exports = __toCommonJS(presentation_content_exports);
37
+ var MAP_TYPES = ["roadmap", "satellite", "hybrid"];
38
+ var DEFAULT_CENTER = [46.6, 2.5];
39
+ var DEFAULT_ZOOM = 6;
40
+ function num(value) {
41
+ const n = Number(value);
42
+ return Number.isFinite(n) ? n : null;
43
+ }
44
+ function pair(value) {
45
+ if (!Array.isArray(value)) return null;
46
+ const a = num(value[0]);
47
+ const b = num(value[1]);
48
+ return a != null && b != null ? [a, b] : null;
49
+ }
50
+ var clamp = (v, min, max) => Math.max(min, Math.min(max, v));
51
+ function sanitizeContent(input) {
52
+ if (!input || typeof input !== "object") return null;
53
+ const c = input;
54
+ if (c.kind === "pdf" || c.kind == null) return null;
55
+ if (c.kind === "streetview") {
56
+ const position = pair(c.position);
57
+ if (!position) return null;
58
+ const rawPov = c.pov && typeof c.pov === "object" ? c.pov : null;
59
+ const pov = rawPov ? { heading: num(rawPov.heading) || 0, pitch: clamp(num(rawPov.pitch) || 0, -90, 90) } : { heading: 0, pitch: 0 };
60
+ return { kind: "streetview", position, pov, zoom: clamp(num(c.zoom) || 1, 0, 5) };
61
+ }
62
+ if (c.kind !== "map") return null;
63
+ return {
64
+ kind: "map",
65
+ center: pair(c.center) || [...DEFAULT_CENTER],
66
+ zoom: clamp(Math.trunc(num(c.zoom) || DEFAULT_ZOOM), 1, 21),
67
+ marker: pair(c.marker),
68
+ mapType: MAP_TYPES.includes(c.mapType) ? c.mapType : null,
69
+ label: String(c.label || "").slice(0, 160) || null
70
+ };
71
+ }
72
+ function cycleMapType(current) {
73
+ const index = MAP_TYPES.indexOf(current);
74
+ return MAP_TYPES[(index + 1) % MAP_TYPES.length];
75
+ }
76
+ function mapTypeLabel(current) {
77
+ if (current === "roadmap") return "\u{1F6F0} Satellite";
78
+ if (current === "satellite") return "\u{1F5FA} Hybride";
79
+ return "\u{1F5FA} Plan";
80
+ }
81
+ function initialMapContent() {
82
+ return { kind: "map", center: [...DEFAULT_CENTER], zoom: DEFAULT_ZOOM, marker: null, mapType: null, label: null };
83
+ }
84
+ // Annotate the CommonJS export names for ESM import in node:
85
+ 0 && (module.exports = {
86
+ DEFAULT_CENTER,
87
+ DEFAULT_ZOOM,
88
+ MAP_TYPES,
89
+ cycleMapType,
90
+ initialMapContent,
91
+ mapTypeLabel,
92
+ sanitizeContent
93
+ });