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.
- package/CONTRAT.md +515 -0
- package/LICENSE +661 -0
- package/LICENSE-MIT +21 -0
- package/README.md +152 -0
- package/bin/__tests__/serve.test.js +84 -0
- package/bin/serve.js +115 -0
- package/context/__tests__/storage.test.js +99 -0
- package/context/standalone.js +224 -0
- package/context/storage.js +230 -0
- package/package.json +72 -0
- package/server/brands.js +44 -0
- package/server/browser.generated.js +7 -0
- package/server/handler.js +2657 -0
- package/server/presentations.js +319 -0
- package/server/shared.generated.js +93 -0
- package/server/shares.js +275 -0
- package/src/__tests__/bridge.test.ts +108 -0
- package/src/__tests__/chat.test.ts +138 -0
- package/src/__tests__/live.test.ts +211 -0
- package/src/__tests__/presentation-content.test.ts +132 -0
- package/src/__tests__/presentation-state.test.ts +81 -0
- package/src/__tests__/tracking.test.ts +217 -0
- package/src/__tests__/viewer.test.ts +133 -0
- package/src/bridge.ts +141 -0
- package/src/chat.ts +103 -0
- package/src/index.ts +14 -0
- package/src/live.ts +225 -0
- package/src/presentation-content.ts +109 -0
- package/src/presentation-state.ts +93 -0
- package/src/tracking.ts +250 -0
- package/src/viewer.ts +109 -0
- package/supabase/init.sql +242 -0
|
@@ -0,0 +1,2657 @@
|
|
|
1
|
+
// Page publique de consultation d'un document commercial : /doc/:slug → visionneuse pdf.js qui TRACE
|
|
2
|
+
// l'ouverture et les PAGES VUES (un lien par destinataire → on sait qui a lu, combien de pages).
|
|
3
|
+
// - GET /doc/:slug → HTML visionneuse (pdf.js depuis cdnjs, nonce CSP)
|
|
4
|
+
// - GET /doc/:slug?file=1 → stream le PDF depuis le Storage (MÊME ORIGINE → pas de souci CORS pour pdf.js)
|
|
5
|
+
// - POST /api/doc {slug,event…}→ journalise un événement (open / page / heartbeat) — best-effort
|
|
6
|
+
const crypto = require("crypto");
|
|
7
|
+
const { getShareBySlug, logView, upsertSession, createReshare, sendReshareEmail, upsertInternalSession,
|
|
8
|
+
createShare, revokeShare, setShareAuth, overview: docOverview, listSharesForDoc, listSessionsForDoc, internalStatsForDoc } = require("./shares");
|
|
9
|
+
const { createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
|
|
10
|
+
// CONTEXTE INJECTÉ : tout ce que le player emprunte à l'application hôte passe par ici — stockage,
|
|
11
|
+
// base, identité, limites, marque, journalisation — et rien d'autre. C'est la frontière qui permettra
|
|
12
|
+
// de brancher un second projet, puis d'ouvrir le cœur. Cf. api/_player-context.js.
|
|
13
|
+
// ⚠️ Le contexte est REÇU (`init`), pas construit ici. Ce fichier ne sait pas quelle application
|
|
14
|
+
// l'héberge — c'est ce qui lui permettra de partir dans le dépôt du player. Le point d'entrée
|
|
15
|
+
// Vercel (api/doc.js) est le seul à connaître le studio.
|
|
16
|
+
let PLAYER = null;
|
|
17
|
+
function init(ctx) {
|
|
18
|
+
PLAYER = ctx;
|
|
19
|
+
// Le domaine reçoit le même contexte : une seule construction pour tout le player.
|
|
20
|
+
require("./shares").init(ctx);
|
|
21
|
+
require("./presentations").init(ctx);
|
|
22
|
+
require("./brands").init(ctx);
|
|
23
|
+
docbot = ctx.plugins.bot;
|
|
24
|
+
brandIntroRuntime = ctx.plugins.brandIntro && ctx.plugins.brandIntro.brandIntroRuntime;
|
|
25
|
+
botBrowser = ctx.plugins.botBrowser;
|
|
26
|
+
}
|
|
27
|
+
const isAllowedStorageUrl = (url) => PLAYER.storage.isAllowedUrl(url);
|
|
28
|
+
|
|
29
|
+
// GREFFONS de ce studio — jamais du player. `null` quand le module est absent ou coupé
|
|
30
|
+
// (`PLAYER_PLUGINS_OFF`) : chaque usage doit donc être gardé, et le player continue sans eux.
|
|
31
|
+
let docbot = null, brandIntroRuntime = null, botBrowser = null;
|
|
32
|
+
// Cœur du player (futur projet open source, cf. player/README.md) : code navigateur écrit en
|
|
33
|
+
// modules TypeScript testés sous player/src/, regroupé par `npm run build:player`. Injecté tel
|
|
34
|
+
// quel dans la visionneuse — `window.Player` y expose le contrat postMessage avec l'app.
|
|
35
|
+
const { PLAYER_BROWSER_JS } = require("./browser.generated.js");
|
|
36
|
+
// Version publiée du player — lue là où elle est déjà déclarée, pour qu'elle ne puisse pas
|
|
37
|
+
// diverger de ce que l'hôte a réellement installé.
|
|
38
|
+
const PLAYER_VERSION = require("../package.json").version;
|
|
39
|
+
// Registre des marques : le loader porte celle du CLIENT dont on montre le document.
|
|
40
|
+
const brands = require("./brands");
|
|
41
|
+
|
|
42
|
+
const esc = (s) => String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
43
|
+
// Content-Disposition sûr : un en-tête HTTP ne tolère QUE de l'ASCII imprimable (un accent/emoji dans le nom de
|
|
44
|
+
// fichier → TypeError ERR_INVALID_CHAR qui casse tout le stream). Repli ASCII pour filename= + RFC 5987 filename*
|
|
45
|
+
// pour conserver le nom unicode exact. (esc() = échappement HTML, inadapté à un en-tête → ne plus l'utiliser ici.)
|
|
46
|
+
const dispositionInline = (name) => {
|
|
47
|
+
const raw = String(name || "document.pdf");
|
|
48
|
+
const ascii = raw.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "").trim() || "document.pdf";
|
|
49
|
+
return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(raw)}`;
|
|
50
|
+
};
|
|
51
|
+
const originOf = (u) => { try { return new URL(u).origin; } catch { return ""; } };
|
|
52
|
+
const PDFJS = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174";
|
|
53
|
+
const SUPAJS = "https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/dist/umd/supabase.js";
|
|
54
|
+
|
|
55
|
+
// ————— Couche LIVE partagée (présence + chat historisé) — présentateur ET audience —————
|
|
56
|
+
// CSS injecté dans les deux vues.
|
|
57
|
+
const LIVE_CSS = `
|
|
58
|
+
.lrow{flex:1;display:flex;min-height:0;position:relative}
|
|
59
|
+
.lmain{flex:1;min-width:0;display:flex;flex-direction:column;position:relative}
|
|
60
|
+
/* Mobile / fenêtre étroite : le chat passe EN SUPERPOSITION (le document garde toute sa largeur). */
|
|
61
|
+
/* Chat mobile = BOTTOM SHEET : monte du bas, poignée pour replier, bouton flottant (FAB) quand fermé.
|
|
62
|
+
Le document se cale en haut → le slide reste visible pendant qu'on discute. #chatPanel (id) bat la spécificité. */
|
|
63
|
+
.chat-grip{display:none}
|
|
64
|
+
/* Bouton flottant chat (mobile) : clair pour ressortir sur le document sombre ; pulse quand non-lus. */
|
|
65
|
+
.chatfab{display:none;position:fixed;right:16px;bottom:calc(18px + env(safe-area-inset-bottom));z-index:38;width:58px;height:58px;border-radius:50%;border:0;background:#faf8f4;color:#1a1a1a;align-items:center;justify-content:center;box-shadow:0 10px 30px rgba(0,0,0,.5);cursor:pointer}
|
|
66
|
+
.chatfab svg{width:26px;height:26px}
|
|
67
|
+
.chatfab.unread{animation:fabPulse 1.7s ease-out infinite}
|
|
68
|
+
@keyframes fabPulse{0%{box-shadow:0 10px 30px rgba(0,0,0,.5),0 0 0 0 rgba(229,56,77,.55)}70%{box-shadow:0 10px 30px rgba(0,0,0,.5),0 0 0 14px rgba(229,56,77,0)}100%{box-shadow:0 10px 30px rgba(0,0,0,.5),0 0 0 0 rgba(229,56,77,0)}}
|
|
69
|
+
.chatfab-badge{position:absolute;top:-5px;right:-5px;min-width:23px;height:23px;padding:0 6px;border-radius:12px;background:#e5384d;color:#fff;font-size:12px;font-weight:800;line-height:23px;display:none;align-items:center;justify-content:center;box-shadow:0 0 0 2px #0a0a0a}
|
|
70
|
+
/* Aperçu (ticker) : mini-bulle du dernier message qui glisse au-dessus du FAB et disparaît. */
|
|
71
|
+
.chatpeek{display:none;position:fixed;right:16px;bottom:calc(86px + env(safe-area-inset-bottom));z-index:38;max-width:76vw;background:#faf8f4;color:#1c1c1c;border-radius:16px;padding:10px 13px;box-shadow:0 12px 34px rgba(0,0,0,.5);gap:9px;align-items:center;opacity:0;transform:translateY(10px);transition:opacity .28s,transform .28s;cursor:pointer}
|
|
72
|
+
.chatpeek.show{display:flex;opacity:1;transform:translateY(0)}
|
|
73
|
+
.chatpeek .peek-a{width:30px;height:30px;border-radius:50%;flex:none;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;background:#e6e2db;color:#555;font-size:12px;font-weight:700}
|
|
74
|
+
.chatpeek .peek-a img{width:100%;height:100%;object-fit:cover}
|
|
75
|
+
.chatpeek .peek-b{min-width:0;display:flex;flex-direction:column;line-height:1.25}
|
|
76
|
+
.chatpeek .peek-b b{font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
77
|
+
.chatpeek .peek-t{font-size:13.5px;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:64vw}
|
|
78
|
+
@media (max-width:720px){
|
|
79
|
+
.chatBtn{display:none!important}
|
|
80
|
+
body:not(.chat-open) .chatfab.on{display:flex}
|
|
81
|
+
#chatPanel{position:fixed;left:0;right:0;bottom:0;top:auto;height:74vh;width:auto;max-width:none;border-left:0;border-radius:18px 18px 0 0;box-shadow:0 -12px 44px rgba(0,0,0,.3);transform:translateY(0);transition:transform .32s cubic-bezier(.22,1,.36,1);z-index:40}
|
|
82
|
+
#chatPanel.hidden{display:flex;transform:translateY(103%)}
|
|
83
|
+
.chat-grip{display:block;width:42px;height:5px;border-radius:3px;background:#0002;margin:9px auto 0;flex:none;cursor:grab}
|
|
84
|
+
.chat-h{padding-top:8px}
|
|
85
|
+
body.chat-open .stage{align-items:flex-start;padding-top:12px}
|
|
86
|
+
/* Lisibilité mobile : corps 15px (au lieu de 13.5), input 16px (empêche le zoom auto iOS au focus).
|
|
87
|
+
Préfixe #chatMsgs/#chatPanel (id) pour battre la spécificité des règles de base définies plus bas. */
|
|
88
|
+
#chatMsgs .cm .txt{font-size:15px;line-height:1.4}
|
|
89
|
+
#chatMsgs .cm .who{font-size:12.5px}
|
|
90
|
+
#chatMsgs .cm .a{width:32px;height:32px;font-size:12px}
|
|
91
|
+
.chat-in input#chatText{font-size:16px}
|
|
92
|
+
#chatPanel .chat-h{font-size:15px}
|
|
93
|
+
}
|
|
94
|
+
.pres{display:none;align-items:center;gap:7px;height:32px;padding:0 11px;border:1px solid #fff3;background:transparent;color:#fff;border-radius:999px;cursor:pointer;font:inherit;font-size:12.5px}
|
|
95
|
+
.pres:hover{background:#fff2}
|
|
96
|
+
.pres .dot{width:7px;height:7px;border-radius:50%;background:#31c76a;flex:none}
|
|
97
|
+
.pres-avs{display:inline-flex}
|
|
98
|
+
.pres-av{width:22px;height:22px;border-radius:50%;margin-left:-7px;border:2px solid var(--bar);background:#8a857c;color:#fff;font-size:10px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;overflow:hidden}
|
|
99
|
+
.pres-av:first-child{margin-left:0}
|
|
100
|
+
.pres-av img{width:100%;height:100%;object-fit:cover}
|
|
101
|
+
.pres-pop{position:fixed;top:52px;right:14px;width:250px;max-height:60vh;overflow:auto;background:#fff;color:#1c1c1c;border-radius:12px;box-shadow:0 18px 54px rgba(0,0,0,.4);padding:6px;z-index:40;display:none}
|
|
102
|
+
.pres-pop.open{display:block}
|
|
103
|
+
.pres-pop h5{margin:6px 8px 6px;font-size:11.5px;color:#888;font-weight:700}
|
|
104
|
+
.pres-item{display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:8px}
|
|
105
|
+
.pres-item .a,.cm .a{width:28px;height:28px;border-radius:50%;flex:none;background:#e6e2db;color:#555;font-size:11px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;overflow:hidden}
|
|
106
|
+
.pres-item .a img,.cm .a img{width:100%;height:100%;object-fit:cover}
|
|
107
|
+
.pres-item .n{font-size:13px;font-weight:600}
|
|
108
|
+
.pres-item .e{font-size:11px;color:#888}
|
|
109
|
+
.tag{font-size:9.5px;font-weight:800;color:#e5384d;text-transform:uppercase;letter-spacing:.02em}
|
|
110
|
+
.chatBtn{display:none;position:relative}
|
|
111
|
+
.chat-badge{display:none;position:absolute;top:-5px;right:-5px;min-width:16px;height:16px;padding:0 4px;border-radius:9px;background:#e5384d;color:#fff;font-size:10px;font-weight:700;line-height:16px;align-items:center;justify-content:center;box-shadow:0 0 0 2px #1a1a1a}
|
|
112
|
+
.chat{width:330px;max-width:82vw;flex:none;background:#faf8f4;border-left:1px solid #0002;display:flex;flex-direction:column;color:#1c1c1c;position:relative;z-index:6}
|
|
113
|
+
.chat.hidden{display:none}
|
|
114
|
+
.chat-h{display:flex;align-items:center;gap:12px;padding:12px 14px;border-bottom:1px solid #0001;font-weight:700;font-size:13.5px}
|
|
115
|
+
.chat-h-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
116
|
+
.chat-h button{border:0;background:transparent;cursor:pointer;color:#999;padding:0;display:inline-flex;align-items:center;line-height:1}
|
|
117
|
+
.chat-h .cx{font-size:20px}
|
|
118
|
+
.chat-h .cd svg{width:17px;height:17px}
|
|
119
|
+
.chat-h button:hover{color:#333}
|
|
120
|
+
/* Cloche « couper les notifs » : barrée + rouge quand actif. */
|
|
121
|
+
#chatMute{position:relative}
|
|
122
|
+
#chatMute.muted{color:#c0392b}
|
|
123
|
+
#chatMute.muted::after{content:'';position:absolute;left:2px;right:2px;top:calc(50% - 1px);height:2px;background:currentColor;border-radius:2px;transform:rotate(-45deg)}
|
|
124
|
+
/* Chat DÉTACHÉ (mode superposé forcé, même sur desktop) — via le bouton dock/undock. */
|
|
125
|
+
.chat.float{position:absolute;top:0;right:0;bottom:0;width:min(360px,90vw);max-width:90vw;box-shadow:-10px 0 40px rgba(0,0,0,.45);z-index:25}
|
|
126
|
+
.chat-msgs{flex:1;overflow:auto;padding:13px;display:flex;flex-direction:column;gap:11px}
|
|
127
|
+
.chat-empty{color:#999;font-size:12.5px;text-align:center;margin:auto}
|
|
128
|
+
.cm{display:flex;gap:8px;align-items:flex-start}
|
|
129
|
+
.cm .b{min-width:0}
|
|
130
|
+
.cm .who{font-size:11.5px;color:#777;margin-bottom:1px}
|
|
131
|
+
.cm .who b{color:#1c1c1c}
|
|
132
|
+
.cm .txt{font-size:13.5px;line-height:1.35;overflow-wrap:anywhere;white-space:pre-wrap}
|
|
133
|
+
.cm-q{border-left:3px solid #d8d2c8;padding:1px 0 1px 8px;margin:0 0 3px;font-size:12px;color:#7a756c;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
134
|
+
.cm-q b{color:#555}
|
|
135
|
+
.cm-re{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
|
|
136
|
+
.re-chip{border:1px solid #e2ddd4;background:#fff;border-radius:999px;padding:1px 8px;font-size:12px;cursor:pointer;color:#333;line-height:1.6}
|
|
137
|
+
.re-chip.on{background:#eef4ff;border-color:#9bb8f0;color:#2f5bd0}
|
|
138
|
+
.cm-act{display:none;gap:3px;align-self:flex-start;margin-top:1px}
|
|
139
|
+
.cm:hover .cm-act{display:inline-flex}
|
|
140
|
+
.cm-act button{border:1px solid #0001;background:#fff;border-radius:7px;width:26px;height:26px;cursor:pointer;font-size:13px;line-height:0;color:#666;display:inline-flex;align-items:center;justify-content:center}
|
|
141
|
+
.cm-act button:hover{background:#f2efe9}
|
|
142
|
+
.cm-del{color:#9a948b;font-style:italic}
|
|
143
|
+
.cm.isdel .cm-act,.cm.isdel .cm-re{display:none}
|
|
144
|
+
.cm-ed{font-size:10.5px;color:#a9a39a}
|
|
145
|
+
.cm-edit-in{width:100%;border:1px solid #c9c3b8;border-radius:7px;padding:5px 8px;font:inherit;font-size:13.5px;background:#fff}
|
|
146
|
+
.chat-locked{padding:6px 14px;font-size:11.5px;color:#b26a00;background:#fdf3e2;border-top:1px solid #f0e2c8;text-align:center;flex:none}
|
|
147
|
+
.chat-in input:disabled{background:#efece7;color:#aaa}
|
|
148
|
+
.chat-in button:disabled{opacity:.5;cursor:default}
|
|
149
|
+
#chatLockBtn.on{color:#e5384d}
|
|
150
|
+
/* Bouton « + » (pièce jointe) façon Apple : petit rond sobre. */
|
|
151
|
+
.chat-in button.chat-attach{border:0;background:#ecebe6;border-radius:50%;cursor:pointer;color:#5a554d;padding:0;flex:none;display:inline-flex;align-items:center;width:34px;height:34px;justify-content:center}
|
|
152
|
+
.chat-in button.chat-attach:hover{color:#1c1c1c;background:#e2e0da}
|
|
153
|
+
.chat-in button.chat-attach svg{width:20px;height:20px;display:block}
|
|
154
|
+
/* Champ + flèche « envoyer » bleue à l'intérieur (Apple SMS), visible dès qu'on tape. */
|
|
155
|
+
.chat-field{position:relative;flex:1;min-width:0;display:flex}
|
|
156
|
+
#chatText{padding-right:44px}
|
|
157
|
+
#chatSend{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:30px;height:30px;min-width:0;padding:0;border-radius:50%;background:#0a84ff;color:#fff;display:none;align-items:center;justify-content:center;box-shadow:0 1px 3px rgba(10,132,255,.35)}
|
|
158
|
+
#chatSend.on{display:inline-flex}
|
|
159
|
+
#chatSend svg{width:19px;height:19px;display:block}
|
|
160
|
+
#chatSend:disabled{background:#c8c4bd;box-shadow:none}
|
|
161
|
+
.cm-att{display:inline-block;margin-top:5px;max-width:210px}
|
|
162
|
+
.cm-att img{max-width:210px;max-height:190px;border-radius:9px;display:block;border:1px solid #0001}
|
|
163
|
+
.cm-att-pdf{display:block;text-decoration:none;color:#333;max-width:210px;margin-top:5px;border:1px solid #e2ddd4;border-radius:10px;overflow:hidden;background:#fff}
|
|
164
|
+
.cm-att-pdf:hover{background:#f8f6f2}
|
|
165
|
+
.cm-att-ph{display:block;width:100%;min-height:54px;background:#f0ede8;position:relative}
|
|
166
|
+
.cm-att-ph canvas,.cm-att-ph img{width:100%;display:block}
|
|
167
|
+
.cm-att-ph::after{content:"PDF";position:absolute;top:6px;left:6px;background:#e5484d;color:#fff;font-size:9px;font-weight:800;padding:1px 6px;border-radius:5px}
|
|
168
|
+
.cm-pdflabel{display:block;font-size:12px;padding:7px 10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-top:1px solid #eee}
|
|
169
|
+
.cm-file{display:inline-flex;align-items:center;gap:7px;margin-top:5px;background:#fff;border:1px solid #e2ddd4;border-radius:9px;padding:7px 11px;font-size:12.5px;color:#333;text-decoration:none;max-width:230px;overflow:hidden;white-space:nowrap}
|
|
170
|
+
.cm-file:hover{background:#f6f4ef}
|
|
171
|
+
.cm-mention{color:#2f5bd0;font-weight:600;background:#eef4ff;border-radius:4px;padding:0 3px}
|
|
172
|
+
.cm-link{color:#2f5bd0;text-decoration:underline;overflow-wrap:anywhere}
|
|
173
|
+
.cm.mentioned{background:#fff8ec;border-radius:8px;margin:0 -4px;padding:2px 4px}
|
|
174
|
+
.mentionpop{position:absolute;left:10px;right:10px;bottom:56px;background:#fff;border-radius:11px;box-shadow:0 12px 34px rgba(0,0,0,.28);padding:5px;display:none;z-index:42;max-height:190px;overflow:auto}
|
|
175
|
+
.mentionpop.open{display:block}
|
|
176
|
+
.mentionpop button{display:flex;align-items:center;gap:8px;width:100%;text-align:left;border:0;background:transparent;padding:6px 8px;border-radius:8px;cursor:pointer;font:inherit;font-size:13px;color:#1c1c1c}
|
|
177
|
+
.mentionpop button:hover,.mentionpop button.sel{background:#f2efe9}
|
|
178
|
+
.mentionpop .a{width:22px;height:22px;font-size:9px}
|
|
179
|
+
.chat-typing{padding:0 14px;height:15px;font-size:11.5px;color:#9a948b;font-style:italic;flex:none}
|
|
180
|
+
.chat-reply{align-items:center;gap:8px;padding:8px 12px;border-top:1px solid #0001;background:#f3f0ea}
|
|
181
|
+
.chat-reply .cq{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:#6a655c}
|
|
182
|
+
.chat-reply .cq b{color:#333}
|
|
183
|
+
.chat-reply button{border:0;background:transparent;font-size:17px;line-height:1;cursor:pointer;color:#999}
|
|
184
|
+
.emojipick{position:fixed;display:none;background:#fff;border-radius:999px;box-shadow:0 10px 34px rgba(0,0,0,.32);padding:4px 6px;z-index:50}
|
|
185
|
+
.emojipick.open{display:flex;gap:1px}
|
|
186
|
+
.emojipick button{border:0;background:transparent;font-size:19px;cursor:pointer;padding:2px 5px;border-radius:8px}
|
|
187
|
+
.emojipick button:hover{background:#f2efe9}
|
|
188
|
+
.chat-in{display:flex;gap:7px;padding:10px;border-top:1px solid #0001}
|
|
189
|
+
.chat-in input{flex:1;min-width:0;border:1px solid #e0dcd4;border-radius:999px;padding:8px 14px;font:inherit;font-size:13px;background:#fff}
|
|
190
|
+
.chat-in button{border:0;background:#1a1a1a;color:#fff;border-radius:999px;padding:0 15px;font:inherit;font-size:13px;font-weight:600;cursor:pointer;flex:none}
|
|
191
|
+
.join{position:fixed;inset:0;background:rgba(20,18,15,.66);display:flex;align-items:center;justify-content:center;z-index:60}
|
|
192
|
+
.join-card{background:#fff;color:#1c1c1c;border-radius:16px;padding:22px;width:330px;max-width:90vw;text-align:center}
|
|
193
|
+
.join-card h4{margin:0 0 5px;font-size:16px}
|
|
194
|
+
.join-card p{margin:0 0 15px;font-size:12.5px;color:#777}
|
|
195
|
+
.join-card input{width:100%;border:1px solid #e0dcd4;border-radius:10px;padding:10px 13px;font:inherit;font-size:14px;margin-bottom:9px}
|
|
196
|
+
.join-card button{width:100%;border:0;background:#1a1a1a;color:#fff;border-radius:11px;padding:11px;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
|
|
197
|
+
/* Modale de confirmation maison (remplace window.confirm dans l'iframe présentation). */
|
|
198
|
+
.lmodal{position:fixed;inset:0;z-index:70;display:none;align-items:center;justify-content:center;background:rgba(20,18,15,.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px)}
|
|
199
|
+
.lmodal.open{display:flex}
|
|
200
|
+
.lmodal-box{background:#faf8f4;border-radius:18px;box-shadow:0 24px 64px rgba(0,0,0,.4);width:min(340px,86vw);padding:22px 22px 16px;text-align:center;animation:lmIn .22s cubic-bezier(.22,1,.36,1)}
|
|
201
|
+
@keyframes lmIn{from{opacity:0;transform:scale(.94) translateY(8px)}to{opacity:1;transform:none}}
|
|
202
|
+
.lmodal-t{font-size:16px;font-weight:700;color:#1a1a1a}
|
|
203
|
+
.lmodal-d{font-size:13px;color:#7a746b;margin-top:6px;line-height:1.4}
|
|
204
|
+
.lmodal-a{display:flex;gap:9px;margin-top:18px}
|
|
205
|
+
.lmodal-a button{flex:1;border:0;border-radius:12px;padding:11px 0;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
|
|
206
|
+
.lmodal-cancel{background:#ecebe6;color:#1c1c1c}
|
|
207
|
+
.lmodal-cancel:hover{background:#e2e0da}
|
|
208
|
+
.lmodal-ok{background:#e5484d;color:#fff}
|
|
209
|
+
.lmodal-ok:hover{background:#d13b40}
|
|
210
|
+
`;
|
|
211
|
+
|
|
212
|
+
// Contrôles de la barre (pastille présence + bouton chat).
|
|
213
|
+
const LIVE_BAR = `<button class=pres id=presBtn><span class=dot></span><span id=presCount>1</span><span class=pres-avs id=presAvs></span></button><button class="ic chatBtn" id=chatBtn title="Discussion"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.9-.9L3 21l1.9-5.6A8.5 8.5 0 1 1 21 11.5z"/></svg><span class=chat-badge id=chatBadge></span></button>`;
|
|
214
|
+
|
|
215
|
+
// Panneau chat (à droite) + popover présence.
|
|
216
|
+
const LIVE_PANEL = `<div class="chat hidden" id=chatPanel><div class=chat-grip id=chatGrip></div><div class=chat-h><span class=chat-h-t>Discussion</span><button class=cd id=chatMute title="Couper les notifications du chat"><svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round stroke-linejoin=round><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></button><button class=cd id=chatLockBtn title="Verrouiller le chat (lecture seule)" style="display:none"><svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round stroke-linejoin=round><rect x=5 y=11 width=14 height=10 rx=2 /><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button><button class=cd id=chatDock title="Ancrer / détacher le chat"><svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round stroke-linejoin=round><rect x=3 y=4 width=18 height=16 rx=2 /><line x1=15 y1=4 x2=15 y2=20 /></svg></button><button class=cx id=chatClose title=Fermer>×</button></div><div class=chat-msgs id=chatMsgs></div><div class=chat-typing id=chatTyping></div><div class=chat-locked id=chatLocked style="display:none">Chat en lecture seule</div><div class=chat-reply id=chatReply style="display:none"></div><div class=mentionpop id=mentionPop></div><div class=chat-in><button class=chat-attach id=chatAttach title="Joindre une image ou un PDF"><svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2.2 stroke-linecap=round><line x1=12 y1=6 x2=12 y2=18 /><line x1=6 y1=12 x2=18 y2=12 /></svg></button><input type=file id=chatFile accept="image/png,image/jpeg,image/webp,image/gif,application/pdf" style="display:none"><div class=chat-field><input id=chatText placeholder="Écrire un message…" maxlength=2000 autocomplete=off><button id=chatSend title=Envoyer><svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2.4 stroke-linecap=round stroke-linejoin=round><line x1=12 y1=20 x2=12 y2=6 /><polyline points="6 12 12 6 18 12" /></svg></button></div></div></div><div class=pres-pop id=presList></div><div class=emojipick id=emojiPick></div><button class=chatfab id=chatFab title="Discussion"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.9-.9L3 21l1.9-5.6A8.5 8.5 0 1 1 21 11.5z"/></svg><span class=chatfab-badge id=chatFabBadge></span></button><div class=chatpeek id=chatPeek></div><div class=lmodal id=lModal><div class=lmodal-box><div class=lmodal-t id=lModalT>Confirmer ?</div><div class=lmodal-d id=lModalD></div><div class=lmodal-a><button class=lmodal-cancel id=lModalNo>Annuler</button><button class=lmodal-ok id=lModalYes>Confirmer</button></div></div></div>`;
|
|
217
|
+
|
|
218
|
+
// JS partagé : présence + chat via Supabase Realtime. Live.connect(slug, me) / Live.disconnect().
|
|
219
|
+
const LIVE_JS = `
|
|
220
|
+
var Live=(function(){
|
|
221
|
+
var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],seen={},msgEls={},msgData={},replyCtx=null,typers={},pdfCache={},_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
|
|
222
|
+
try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
|
|
223
|
+
// Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
|
|
224
|
+
function applyMute(){ var b=document.getElementById('chatMute'); if(b){b.classList.toggle('muted',MUTED);b.title=MUTED?'Réactiver les notifications du chat':'Couper les notifications du chat';} setBadge(); }
|
|
225
|
+
function toggleMute(){ MUTED=!MUTED; try{ localStorage.setItem('3dd-present-mute',MUTED?'1':'0'); }catch(e){} if(MUTED)hidePeek(); applyMute(); }
|
|
226
|
+
// Flèche « envoyer » : visible seulement si le champ contient du texte et qu'on peut poster.
|
|
227
|
+
function toggleSend(){ var s=document.getElementById('chatSend'),t=document.getElementById('chatText'); if(!s||!t)return; var can=!(LOCKED&&!canMod()); s.classList.toggle('on', can && (t.value||'').trim().length>0); }
|
|
228
|
+
function sendMap(p){try{if(ch)ch.send({type:'broadcast',event:'map',payload:p});}catch(e){}}
|
|
229
|
+
function onMap(fn){_onMap=fn;}
|
|
230
|
+
// État de la présentation diffusé par le présentateur — même canal que la carte. Sert à se
|
|
231
|
+
// passer de la lecture anonyme des tables : l'audience n'a plus besoin de lire la ligne.
|
|
232
|
+
function sendState(p){try{if(ch)ch.send({type:'broadcast',event:'state',payload:p});}catch(e){}}
|
|
233
|
+
function onState(fn){_onState=fn;}
|
|
234
|
+
// Badge « non lus » sur le bouton chat/FAB (panneau fermé) + pulse du FAB. Aperçu (ticker) au nouveau message.
|
|
235
|
+
function chatHidden(){var pn=document.getElementById('chatPanel');return !pn||pn.classList.contains('hidden');}
|
|
236
|
+
function setBadge(){var t=Player.live.unreadLabel(unread); ['chatBadge','chatFabBadge'].forEach(function(id){var b=document.getElementById(id);if(!b)return;if(unread>0){b.textContent=t;b.style.display='flex';}else{b.style.display='none';}}); var fab=document.getElementById('chatFab'); if(fab)fab.classList.toggle('unread',unread>0&&!MUTED);}
|
|
237
|
+
function clearUnread(){unread=0;setBadge();}
|
|
238
|
+
// Aperçu du dernier message : mini-bulle qui glisse au-dessus du FAB puis disparaît (~4s), tappable → ouvre.
|
|
239
|
+
function showPeek(m){ var pk=document.getElementById('chatPeek'); if(!pk||!isOverlay())return; var nm=m.author_name||'Invité'; var bd=m.deleted?'Message supprimé':((m.body&&m.body.trim())||(m.attachment?'📎 Pièce jointe':'')); if(!bd)return; pk.innerHTML='<span class=peek-a>'+av(m.author_avatar,m.author_name)+'</span><span class=peek-b><b>'+esc(nm)+'</b><span class=peek-t>'+esc(bd.slice(0,90))+'</span></span>'; pk.classList.add('show'); clearTimeout(_peekT); _peekT=setTimeout(function(){pk.classList.remove('show');},4200); }
|
|
240
|
+
function hidePeek(){ var pk=document.getElementById('chatPeek'); if(pk)pk.classList.remove('show'); clearTimeout(_peekT); }
|
|
241
|
+
// Ouvre le chat. Sur mobile = bottom sheet (le document se cale en haut, le slide reste visible) ; on ne
|
|
242
|
+
// met PAS le focus (le clavier couvrirait la feuille). Sur desktop = panneau latéral.
|
|
243
|
+
function openChatPanel(){var pn=document.getElementById('chatPanel');if(!pn)return;var mob=isOverlay();hidePeek();pn.classList.remove('hidden');if(mob)document.body.classList.add('chat-open');clearUnread();var t=document.getElementById('chatText');if(t&&!mob)t.focus();var box=document.getElementById('chatMsgs');if(box)box.scrollTop=box.scrollHeight;if(window.__refit)setTimeout(window.__refit,mob?340:60);}
|
|
244
|
+
function closeChatPanel(){var pn=document.getElementById('chatPanel');if(pn)pn.classList.add('hidden');document.body.classList.remove('chat-open');if(window.__refit)setTimeout(window.__refit,340);}
|
|
245
|
+
// Nouveau message reçu, chat fermé : badge + pulse FAB + aperçu (ticker). Plus d'auto-ouverture (trop intrusif).
|
|
246
|
+
function notifyMsg(m){if(!Player.live.shouldNotify({msg:m,me:ME,historyLoaded:_histDone,chatHidden:chatHidden()}))return;unread++;setBadge();if(!MUTED)showPeek(m);}
|
|
247
|
+
var MYID=Math.random().toString(36).slice(2,9);
|
|
248
|
+
function _store(){try{return window.localStorage;}catch(e){return null;}}
|
|
249
|
+
// Clé d'assistance STABLE (analytics de présentation) : email si connu, sinon id persistant par navigateur.
|
|
250
|
+
function attKey(me){ return Player.live.attendeeKey(me,_store(),MYID); }
|
|
251
|
+
// Heartbeat d'assistance → le serveur journalise qui suit, combien de temps, et les pages vues (via la page
|
|
252
|
+
// courante de la présentation). Envoyé à la connexion puis toutes les 25 s. Best-effort (silencieux).
|
|
253
|
+
function sendAttend(){ if(!SLUG||!ME)return; try{ fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-attend',slug:SLUG,key:attKey(ME),name:ME.name||'',email:ME.email||'',avatar:ME.avatar||'',isMember:!!ME.member,isPresenter:ME.role==='presenter'})}); }catch(e){} }
|
|
254
|
+
var EMOJIS=['👍','❤️','😂','😮','👏','🎉'];
|
|
255
|
+
var RSVG='<svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round><circle cx=12 cy=12 r=9 /><path d="M8.5 14.5s1.4 1.7 3.5 1.7 3.5-1.7 3.5-1.7"/><line x1=9 y1=9.2 x2=9.01 y2=9.2 /><line x1=15 y1=9.2 x2=15.01 y2=9.2 /></svg>';
|
|
256
|
+
function esc(s){return Player.live.escapeHtml(s);}
|
|
257
|
+
function ini(n){return Player.live.initials(n);}
|
|
258
|
+
function av(u,n){return Player.live.avatarHtml(u,n);}
|
|
259
|
+
function isOverlay(){var pn=document.getElementById('chatPanel');return !!(window.matchMedia&&window.matchMedia('(max-width:720px)').matches)||!!(pn&&pn.classList.contains('float'));}
|
|
260
|
+
// Aplatit l'état de présence en DÉDOUBLONNANT par identité (email, sinon nom) → un participant reconnecté
|
|
261
|
+
// (nouveau MYID) ou un fantôme websocket non nettoyé n'apparaît qu'une fois. On garde la méta présentateur si dispo.
|
|
262
|
+
function flat(st){return Player.live.flattenPresence(st);}
|
|
263
|
+
function reactorId(){return Player.live.reactorId(ME);}
|
|
264
|
+
function authToken(){if(!AUTHTOK)AUTHTOK=Player.live.authorToken(_store());return AUTHTOK;}
|
|
265
|
+
function mineOf(m){return Player.live.isMine(m,ME);}
|
|
266
|
+
function canMod(){return Player.live.canModerate(ME);}
|
|
267
|
+
function fmt(s){return Player.live.formatMessageBody(s);}
|
|
268
|
+
function isMentioned(m){return Player.live.isMentioned(m,ME);}
|
|
269
|
+
function renderPres(st){var l=flat(st),c=l.length;PRESENT=l;var e=document.getElementById('presCount');if(e)e.textContent=c;
|
|
270
|
+
var a=document.getElementById('presAvs');if(a)a.innerHTML=l.slice(0,4).map(function(m){return '<span class=pres-av>'+av(m.avatar,m.name)+'</span>';}).join('');
|
|
271
|
+
var p=document.getElementById('presList');if(p)p.innerHTML='<h5>'+c+' en ligne</h5>'+l.map(function(m){return '<div class=pres-item><span class=a>'+av(m.avatar,m.name)+'</span><span class=b><div class=n>'+esc(m.name||'Invité')+(m.role==='presenter'?' <span class=tag>présentateur</span>':'')+'</div>'+(m.email?'<div class=e>'+esc(m.email)+'</div>':'')+'</span></div>';}).join('');}
|
|
272
|
+
// Rendu d'un message → player/src/chat.ts (échappement testé sous jsdom : on y vérifie ce que
|
|
273
|
+
// le NAVIGATEUR fabrique, pas seulement la chaîne produite).
|
|
274
|
+
function renderRe(m){return Player.chat.renderReactions(m,ME);}
|
|
275
|
+
function renderMsgInner(m){return Player.chat.renderMessage(m,{me:ME,reactIcon:RSVG});}
|
|
276
|
+
function cmClass(m){return Player.chat.messageClassName(m,ME,isMentioned(m));}
|
|
277
|
+
function hydratePdf(d,m){if(m&&m.attachment&&m.attachment.kind==='pdf'&&!m.deleted){var ph=d.querySelector('.cm-att-ph');if(ph)pdfThumb(m.attachment.url,ph);}}
|
|
278
|
+
function pdfThumb(url,ph){if(!ph)return;if(pdfCache[url]){ph.innerHTML='<img src="'+pdfCache[url]+'" alt="">';return;}if(!window.pdfjsLib)return;try{pdfjsLib.getDocument(url).promise.then(function(pdf){return pdf.getPage(1);}).then(function(pg){var v0=pg.getViewport({scale:1}),sc=Math.min(1.6,208/v0.width),vp=pg.getViewport({scale:sc}),cv=document.createElement('canvas');cv.width=Math.ceil(vp.width);cv.height=Math.ceil(vp.height);return pg.render({canvasContext:cv.getContext('2d'),viewport:vp}).promise.then(function(){var u=cv.toDataURL('image/jpeg',0.8);pdfCache[url]=u;ph.innerHTML='<img src="'+u+'" alt="">';});}).catch(function(){});}catch(e){}}
|
|
279
|
+
function addMsg(m){if(m.id&&seen[m.id])return;if(m.id)seen[m.id]=1;var box=document.getElementById('chatMsgs');if(!box)return;var em=box.querySelector('.chat-empty');if(em)em.remove();
|
|
280
|
+
if(m.id)msgData[m.id]=m;
|
|
281
|
+
var d=document.createElement('div');d.className=cmClass(m);if(m.id)d.setAttribute('data-id',m.id);
|
|
282
|
+
d.innerHTML=renderMsgInner(m);
|
|
283
|
+
if(m.id)msgEls[m.id]=d;box.appendChild(d);box.scrollTop=box.scrollHeight;hydratePdf(d,m);}
|
|
284
|
+
function updateMsg(m){if(!m.id)return;msgData[m.id]=m;var d=msgEls[m.id];if(!d)return;d.className=cmClass(m);d.innerHTML=renderMsgInner(m);hydratePdf(d,m);}
|
|
285
|
+
function startEdit(id){var d=msgEls[id],m=msgData[id];if(!d||!m||m.deleted)return;var txt=d.querySelector('.txt');if(!txt)return;var inp=document.createElement('input');inp.className='cm-edit-in';inp.value=m.body||'';txt.replaceWith(inp);inp.focus();
|
|
286
|
+
function fin(save){var v=(inp.value||'').trim();if(save&&v&&v!==m.body){fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-msg-edit',slug:SLUG,msgId:+id,authorToken:authToken(),body:v})}).catch(function(){});}d.innerHTML=renderMsgInner(m);}
|
|
287
|
+
inp.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();fin(true);}else if(e.key==='Escape'){fin(false);}});
|
|
288
|
+
inp.addEventListener('blur',function(){fin(false);});}
|
|
289
|
+
function delMsg(id){fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-msg-delete',slug:SLUG,msgId:+id,authorToken:authToken(),control:CONTROL})}).catch(function(){});}
|
|
290
|
+
// Modale de confirmation maison (l'iframe de présentation ne peut pas utiliser le useConfirm React). Repli window.confirm si absente.
|
|
291
|
+
function confirmDialog(opts,onOk){opts=opts||{};var m=document.getElementById('lModal');if(!m){if(!onOk)return;if(window.confirm(opts.title||'Confirmer ?'))onOk();return;}
|
|
292
|
+
var t=document.getElementById('lModalT'),d=document.getElementById('lModalD'),y=document.getElementById('lModalYes'),n=document.getElementById('lModalNo');
|
|
293
|
+
if(t)t.textContent=opts.title||'Confirmer ?';if(d){d.textContent=opts.desc||'';d.style.display=opts.desc?'block':'none';}
|
|
294
|
+
if(y)y.textContent=opts.ok||'Confirmer';
|
|
295
|
+
function close(){m.classList.remove('open');if(y)y.onclick=null;if(n)n.onclick=null;m.onclick=null;document.removeEventListener('keydown',key);}
|
|
296
|
+
function key(e){if(e.key==='Escape'){close();}else if(e.key==='Enter'){close();if(onOk)onOk();}}
|
|
297
|
+
if(y)y.onclick=function(){close();if(onOk)onOk();};if(n)n.onclick=close;m.onclick=function(e){if(e.target===m)close();};
|
|
298
|
+
document.addEventListener('keydown',key);m.classList.add('open');if(y)try{y.focus();}catch(e){}}
|
|
299
|
+
function history(){fetch('/api/doc?present='+encodeURIComponent(SLUG)+'&chat=1').then(function(r){return r.json();}).then(function(d){var box=document.getElementById('chatMsgs');if(d&&d.messages&&d.messages.length){d.messages.forEach(function(m){addMsg(m);});}else if(box&&!box.children.length){box.innerHTML='<div class=chat-empty>Aucun message. Lancez la discussion.</div>';}if(d&&typeof d.locked!=='undefined')applyLock(d.locked);_histDone=true;}).catch(function(){_histDone=true;});}
|
|
300
|
+
function react(id,e){if(!ME||!id||!e)return;fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:reactorId()})}).catch(function(){});}
|
|
301
|
+
function setReply(id){var m=msgData[id];if(!m||m.deleted)return;var nm=m.author_name||'Invité';replyCtx={id:+id,name:nm,text:(m.body||'').slice(0,120)};var el=document.getElementById('chatReply');if(el){el.style.display='flex';el.innerHTML='<span class=cq><b>'+esc(nm)+'</b> '+esc((m.body||'').slice(0,80))+'</span><button id=chatReplyX title=Annuler>×</button>';var x=document.getElementById('chatReplyX');if(x)x.addEventListener('click',clearReply);}var t=document.getElementById('chatText');if(t)t.focus();}
|
|
302
|
+
function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
|
|
303
|
+
function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
|
|
304
|
+
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,isPresenter:ME.role==='presenter',isMember:!!ME.member,body:t,authorToken:authToken()};
|
|
305
|
+
if(CONTROL)o.control=CONTROL;
|
|
306
|
+
if(replyCtx){o.replyTo=replyCtx.id;o.replyName=replyCtx.name;o.replyText=replyCtx.text;clearReply();}
|
|
307
|
+
fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)}).catch(function(){});}
|
|
308
|
+
function uploadFile(file){if(!file||!ME||!sb)return;if(LOCKED&&!canMod())return;
|
|
309
|
+
if(file.size>10*1024*1024){alert('Fichier trop volumineux (max 10 Mo).');return;}
|
|
310
|
+
var s=document.getElementById('chatSend');if(s){s.disabled=true;s.textContent='…';}
|
|
311
|
+
function done(){if(s){s.disabled=(LOCKED&&!canMod());s.textContent='Envoyer';}}
|
|
312
|
+
fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-upload-url',slug:SLUG,name:file.name,type:file.type})}).then(function(r){return r.json();}).then(function(d){
|
|
313
|
+
if(!d||!d.ok||!d.token)throw 0;
|
|
314
|
+
return sb.storage.from('present-attachments').uploadToSignedUrl(d.path,d.token,file).then(function(u){
|
|
315
|
+
if(u&&u.error)throw 0;
|
|
316
|
+
var i=document.getElementById('chatText'),cap=(i&&i.value||'').trim();if(i)i.value='';
|
|
317
|
+
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,isPresenter:ME.role==='presenter',isMember:!!ME.member,body:cap,authorToken:authToken(),attachment:{url:d.publicUrl,name:file.name,type:file.type,kind:d.kind}};
|
|
318
|
+
if(CONTROL)o.control=CONTROL;
|
|
319
|
+
return fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)});
|
|
320
|
+
});
|
|
321
|
+
}).then(done).catch(function(){done();});}
|
|
322
|
+
function applyLock(v){LOCKED=!!v;var t=document.getElementById('chatText'),s=document.getElementById('chatSend');var can=!LOCKED||canMod();if(t){t.disabled=!can;t.placeholder=can?'Écrire un message…':'Chat en lecture seule';}if(s)s.disabled=!can;var lk=document.getElementById('chatLockBtn');if(lk)lk.classList.toggle('on',LOCKED);var no=document.getElementById('chatLocked');if(no)no.style.display=(LOCKED&&!canMod())?'block':'none';toggleSend();}
|
|
323
|
+
function toggleLock(){if(!canMod())return;var nv=!LOCKED;fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-chatlock',slug:SLUG,control:CONTROL,locked:nv})}).then(function(){applyLock(nv);try{if(ch)ch.send({type:'broadcast',event:'lock',payload:{locked:nv}});}catch(e){}}).catch(function(){});}
|
|
324
|
+
function pingTyping(){var n=Date.now();if(n-_tyT<1600)return;_tyT=n;try{if(ch)ch.send({type:'broadcast',event:'typing',payload:{id:MYID,name:ME&&ME.name}});}catch(e){}}
|
|
325
|
+
function onTyping(p){if(!p||p.id===MYID)return;typers[p.id]={name:p.name,t:Date.now()};renderTyping();}
|
|
326
|
+
function renderTyping(){var el=document.getElementById('chatTyping');if(!el)return;var n=Date.now(),names=[];for(var k in typers){if(n-typers[k].t<4200)names.push(typers[k].name||'Quelqu\\'un');else delete typers[k];}el.textContent=names.length?(names.slice(0,2).join(', ')+(names.length>1?' écrivent…':' écrit…')):'';}
|
|
327
|
+
function openPicker(btn,id){var p=document.getElementById('emojiPick');if(!p)return;p.__id=id;var r=btn.getBoundingClientRect();p.style.left=Math.max(8,Math.min(r.left-120,window.innerWidth-200))+'px';p.style.top=Math.max(8,r.top-44)+'px';p.classList.add('open');}
|
|
328
|
+
function mentionCheck(){var t=document.getElementById('chatText'),pop=document.getElementById('mentionPop');if(!t||!pop)return;var pos=t.selectionStart||0,pre=t.value.slice(0,pos),mm=pre.match(/@([\\p{L}0-9_'.-]*)$/u);if(!mm){pop.classList.remove('open');return;}var q=(mm[1]||'').toLowerCase();var seenN={},uniq=[];(PRESENT||[]).forEach(function(p){if(!p.name)return;var k=p.name.toLowerCase();if(seenN[k]||(ME&&p.name===ME.name))return;if(q&&k.indexOf(q)<0)return;seenN[k]=1;uniq.push(p);});uniq=uniq.slice(0,6);if(!uniq.length){pop.classList.remove('open');return;}pop.__len=(mm[1]||'').length;pop.innerHTML=uniq.map(function(p,i){return '<button class="'+(i===0?'sel':'')+'" data-n="'+esc(p.name)+'"><span class=a>'+av(p.avatar,p.name)+'</span>'+esc(p.name)+'</button>';}).join('');pop.classList.add('open');}
|
|
329
|
+
function pickMention(name){var t=document.getElementById('chatText'),pop=document.getElementById('mentionPop');if(!t)return;var pos=t.selectionStart||t.value.length,len=(pop&&pop.__len)||0,before=t.value.slice(0,pos-len-1),after=t.value.slice(pos),ins='@'+name+' ';t.value=before+ins+after;var np=before.length+ins.length;t.focus();try{t.setSelectionRange(np,np);}catch(e){}if(pop)pop.classList.remove('open');}
|
|
330
|
+
function wire(){var s=document.getElementById('chatSend'),t=document.getElementById('chatText'),cb=document.getElementById('chatBtn'),cl=document.getElementById('chatClose'),pn=document.getElementById('chatPanel'),pb=document.getElementById('presBtn'),pp=document.getElementById('presList'),box=document.getElementById('chatMsgs'),pick=document.getElementById('emojiPick');
|
|
331
|
+
if(s&&!s._w){s._w=1;s.addEventListener('click',send);
|
|
332
|
+
t.addEventListener('keydown',function(e){var pop=document.getElementById('mentionPop'),open=pop&&pop.classList.contains('open');
|
|
333
|
+
if(open&&(e.key==='Enter'||e.key==='Tab')){var b=pop.querySelector('button.sel')||pop.querySelector('button');if(b){e.preventDefault();pickMention(b.getAttribute('data-n'));return;}}
|
|
334
|
+
if(open&&(e.key==='ArrowDown'||e.key==='ArrowUp')){e.preventDefault();var bs=pop.querySelectorAll('button'),si=-1,i;for(i=0;i<bs.length;i++)if(bs[i].classList.contains('sel'))si=i;if(si>=0)bs[si].classList.remove('sel');var ni=e.key==='ArrowDown'?(si+1)%bs.length:(si-1+bs.length)%bs.length;bs[ni].classList.add('sel');return;}
|
|
335
|
+
if(open&&e.key==='Escape'){pop.classList.remove('open');return;}
|
|
336
|
+
if(e.key==='Enter'){e.preventDefault();send();}});
|
|
337
|
+
t.addEventListener('input',function(){pingTyping();mentionCheck();toggleSend();});}
|
|
338
|
+
var mp=document.getElementById('mentionPop');
|
|
339
|
+
if(mp&&!mp._w){mp._w=1;mp.addEventListener('mousedown',function(e){var b=e.target.closest?e.target.closest('button'):null;if(b){e.preventDefault();pickMention(b.getAttribute('data-n'));}});}
|
|
340
|
+
var af=document.getElementById('chatAttach'),ff=document.getElementById('chatFile');
|
|
341
|
+
if(af&&ff&&!af._w){af._w=1;af.addEventListener('click',function(){ff.click();});ff.addEventListener('change',function(){if(ff.files&&ff.files[0])uploadFile(ff.files[0]);ff.value='';});}
|
|
342
|
+
if(cb&&!cb._w){cb._w=1;cb.addEventListener('click',function(){ if(pn.classList.contains('hidden'))openChatPanel(); else closeChatPanel(); });}
|
|
343
|
+
if(cl&&!cl._w){cl._w=1;cl.addEventListener('click',closeChatPanel);}
|
|
344
|
+
// Mobile : bouton flottant (FAB) pour ouvrir la feuille ; poignée pour la replier (tap ou swipe vers le bas).
|
|
345
|
+
var fab=document.getElementById('chatFab'); if(fab&&!fab._w){fab._w=1;fab.addEventListener('click',openChatPanel);}
|
|
346
|
+
var peek=document.getElementById('chatPeek'); if(peek&&!peek._w){peek._w=1;peek.addEventListener('click',openChatPanel);}
|
|
347
|
+
var mute=document.getElementById('chatMute'); if(mute&&!mute._w){mute._w=1;mute.addEventListener('click',toggleMute);applyMute();}
|
|
348
|
+
toggleSend();
|
|
349
|
+
var grip=document.getElementById('chatGrip');
|
|
350
|
+
if(grip&&!grip._w){grip._w=1; var _gy=0,_gd=0,_gdrag=false;
|
|
351
|
+
grip.addEventListener('touchstart',function(e){ _gy=e.touches[0].clientY; _gd=0; _gdrag=true; pn.style.transition='none'; },{passive:true});
|
|
352
|
+
grip.addEventListener('touchmove',function(e){ if(!_gdrag)return; _gd=Math.max(0,e.touches[0].clientY-_gy); pn.style.transform='translateY('+_gd+'px)'; },{passive:true});
|
|
353
|
+
grip.addEventListener('touchend',function(){ if(!_gdrag)return; _gdrag=false; pn.style.transition=''; pn.style.transform=''; if(_gd>90||_gd<6) closeChatPanel(); });
|
|
354
|
+
grip.addEventListener('click',function(){ if(!('ontouchstart' in window)) closeChatPanel(); }); // souris (desktop réduit) seulement
|
|
355
|
+
}
|
|
356
|
+
var dk=document.getElementById('chatDock');
|
|
357
|
+
if(dk&&!dk._w){dk._w=1; try{ if(localStorage.getItem('3dd-chat-float')==='1') pn.classList.add('float'); }catch(e){}
|
|
358
|
+
dk.addEventListener('click',function(){var f=pn.classList.toggle('float');try{localStorage.setItem('3dd-chat-float',f?'1':'0');}catch(e){} if(window.__refit) setTimeout(window.__refit,60);});}
|
|
359
|
+
if(pb&&!pb._w){pb._w=1;pb.addEventListener('click',function(e){e.stopPropagation();pp.classList.toggle('open');});pp.addEventListener('click',function(e){e.stopPropagation();});document.addEventListener('click',function(){pp.classList.remove('open');});}
|
|
360
|
+
// Actions sur les messages (délégation) : chip réaction, bouton réagir (picker), bouton répondre.
|
|
361
|
+
if(box&&!box._w){box._w=1;box.addEventListener('click',function(e){
|
|
362
|
+
var chip=e.target.closest?e.target.closest('.re-chip'):null;if(chip){var c1=chip.closest('.cm');if(c1)react(c1.getAttribute('data-id'),chip.getAttribute('data-e'));return;}
|
|
363
|
+
var rb=e.target.closest?e.target.closest('.cm-react'):null;if(rb){var c2=rb.closest('.cm');if(c2)openPicker(rb,c2.getAttribute('data-id'));return;}
|
|
364
|
+
var rp=e.target.closest?e.target.closest('.cm-reply'):null;if(rp){var c3=rp.closest('.cm');if(c3)setReply(c3.getAttribute('data-id'));return;}
|
|
365
|
+
var ee=e.target.closest?e.target.closest('.cm-edit'):null;if(ee){var c4=ee.closest('.cm');if(c4)startEdit(c4.getAttribute('data-id'));return;}
|
|
366
|
+
var dd=e.target.closest?e.target.closest('.cm-del-btn'):null;if(dd){var c5=dd.closest('.cm');if(c5)confirmDialog({title:'Supprimer ce message ?',desc:'Ce message sera retiré de la discussion pour tout le monde.',ok:'Supprimer'},function(){delMsg(c5.getAttribute('data-id'));});}
|
|
367
|
+
});}
|
|
368
|
+
var lb=document.getElementById('chatLockBtn');
|
|
369
|
+
if(lb&&!lb._w){lb._w=1;if(canMod())lb.style.display='inline-flex';lb.addEventListener('click',toggleLock);}
|
|
370
|
+
if(pick&&!pick._w){pick._w=1;pick.innerHTML=EMOJIS.map(function(e){return '<button data-e="'+e+'">'+e+'</button>';}).join('');
|
|
371
|
+
pick.addEventListener('click',function(e){e.stopPropagation();var b=e.target.closest?e.target.closest('button'):null;if(b){react(pick.__id,b.getAttribute('data-e'));pick.classList.remove('open');}});
|
|
372
|
+
document.addEventListener('click',function(){pick.classList.remove('open');});}}
|
|
373
|
+
function connect(slug,me,control){if(!window.supabase||!LIVECFG.supaUrl||!LIVECFG.supaKey||!slug)return;SLUG=slug;ME=me;CONTROL=control||null;
|
|
374
|
+
var pb=document.getElementById('presBtn');if(pb)pb.style.display='inline-flex';
|
|
375
|
+
var cb=document.getElementById('chatBtn');if(cb)cb.style.display='inline-flex';var _fb=document.getElementById('chatFab');if(_fb)_fb.classList.add('on');
|
|
376
|
+
wire();history();
|
|
377
|
+
try{sb=window.supabase.createClient(LIVECFG.supaUrl,LIVECFG.supaKey,{realtime:{params:{eventsPerSecond:10}}});
|
|
378
|
+
ch=sb.channel('plive-'+slug,{config:{presence:{key:(me.email||me.name||'x')+':'+MYID}}});
|
|
379
|
+
ch.on('presence',{event:'sync'},function(){renderPres(ch.presenceState());});
|
|
380
|
+
ch.on('postgres_changes',{event:'INSERT',schema:'public',table:'doc_presentation_messages',filter:'slug=eq.'+slug},function(p){if(p&&p.new){addMsg(p.new);notifyMsg(p.new);}});
|
|
381
|
+
ch.on('postgres_changes',{event:'UPDATE',schema:'public',table:'doc_presentation_messages',filter:'slug=eq.'+slug},function(p){if(p&&p.new)updateMsg(p.new);});
|
|
382
|
+
ch.on('broadcast',{event:'typing'},function(p){onTyping(p&&p.payload);});
|
|
383
|
+
ch.on('broadcast',{event:'lock'},function(p){if(p&&p.payload)applyLock(p.payload.locked);});
|
|
384
|
+
ch.on('broadcast',{event:'map'},function(p){if(_onMap&&p&&p.payload)_onMap(p.payload);});
|
|
385
|
+
ch.on('broadcast',{event:'state'},function(p){if(_onState&&p&&p.payload)_onState(p.payload);});
|
|
386
|
+
ch.subscribe(function(st){if(st==='SUBSCRIBED'){ch.track({name:me.name,email:me.email,avatar:me.avatar,role:me.role,member:!!me.member,uid:attKey(me)});sendAttend();}});
|
|
387
|
+
_tyIv=setInterval(renderTyping,1500);
|
|
388
|
+
_atIv=setInterval(sendAttend,25000);
|
|
389
|
+
// Filet de sécurité : au déchargement de la page/iframe (fermeture, reload, switch), on retire la présence
|
|
390
|
+
// → évite les fantômes (« je me vois deux fois » au retour). Une seule fois.
|
|
391
|
+
if(!_phWired){_phWired=true;window.addEventListener('pagehide',function(){try{if(ch){ch.untrack();ch.unsubscribe();ch=null;}}catch(e){}});}
|
|
392
|
+
}catch(e){}}
|
|
393
|
+
function disconnect(){try{clearInterval(_tyIv);}catch(e){}try{clearInterval(_atIv);}catch(e){}try{sendAttend();}catch(e){}try{if(ch){ch.untrack();ch.unsubscribe();ch=null;}}catch(e){}var pb=document.getElementById('presBtn');if(pb)pb.style.display='none';var cb=document.getElementById('chatBtn');if(cb)cb.style.display='none';var _fb=document.getElementById('chatFab');if(_fb)_fb.classList.remove('on');var pn=document.getElementById('chatPanel');if(pn)pn.classList.add('hidden');}
|
|
394
|
+
// Membre de l'équipe reconnu via la session app (MÊME ORIGINE, localStorage) → avatar + nom auto.
|
|
395
|
+
function detectMember(){try{var raw=localStorage.getItem('3dd-supabase-auth');if(!raw)return null;var s=JSON.parse(raw);var u=s&&(s.user||(s.currentSession&&s.currentSession.user)||(s.session&&s.session.user));if(u&&u.email){var m=u.user_metadata||{};return{name:m.name||u.email,email:u.email,avatar:m.avatarUrl||'',member:true,role:'viewer'};}}catch(e){}return null;}
|
|
396
|
+
return {connect:connect,disconnect:disconnect,detectMember:detectMember,sendMap:sendMap,onMap:onMap,sendState:sendState,onState:onState};
|
|
397
|
+
})();`;
|
|
398
|
+
|
|
399
|
+
// Mode « Carte live » : overlay Leaflet/OpenStreetMap partagé entre le présentateur (interactif : recherche,
|
|
400
|
+
// pan, zoom, marqueur) et l'audience (suit en direct). Position « posée » persistée via present-content (pour
|
|
401
|
+
// les arrivées tardives) ; mouvements fins diffusés via Live.sendMap (broadcast Realtime). Chargé à la demande.
|
|
402
|
+
const MAP_CSS = `
|
|
403
|
+
#mapWrap{position:absolute;inset:0;z-index:20;display:none;background:#e9e5df}
|
|
404
|
+
#mapWrap.on{display:block}
|
|
405
|
+
#map3dd{position:absolute;inset:0;isolation:isolate}
|
|
406
|
+
#svPano{position:absolute;inset:0;display:none;isolation:isolate}
|
|
407
|
+
#mapWrap.sv #svPano{display:block}
|
|
408
|
+
#mapWrap.sv #map3dd,#mapWrap.sv .map-search,#mapWrap.sv #mapSV,#mapWrap.sv .map-type{display:none!important}
|
|
409
|
+
.map-sv{position:absolute;bottom:12px;left:12px;z-index:30;border:0;background:#1a1a1a;color:#fff;border-radius:999px;padding:9px 15px;font:inherit;font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.25)}
|
|
410
|
+
.map-tomap{position:absolute;top:12px;left:12px;z-index:30;border:0;background:#fff;color:#1a1a1a;border-radius:999px;padding:9px 15px;font:inherit;font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.2)}
|
|
411
|
+
.map-type{position:absolute;bottom:54px;left:12px;z-index:30;border:0;background:#fff;color:#1a1a1a;border-radius:999px;padding:8px 14px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.2)}
|
|
412
|
+
.lmain{position:relative}
|
|
413
|
+
.map-search{position:absolute;top:12px;left:12px;z-index:30;width:min(360px,72%);background:#fff;border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.2);overflow:hidden}
|
|
414
|
+
.map-search input{width:100%;border:0;padding:11px 14px;font:inherit;font-size:14px;outline:none;box-sizing:border-box}
|
|
415
|
+
.map-res{max-height:240px;overflow:auto}
|
|
416
|
+
.map-res button{display:block;width:100%;text-align:left;border:0;background:none;padding:9px 14px;font:inherit;font-size:12.5px;line-height:1.35;cursor:pointer;border-top:1px solid #eee;color:#1c1c1c}
|
|
417
|
+
.map-res button:hover{background:#f3f1ec}
|
|
418
|
+
.map-back{position:absolute;top:12px;right:12px;z-index:30;border:0;background:#1a1a1a;color:#fff;border-radius:999px;padding:9px 15px;font:inherit;font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.25)}
|
|
419
|
+
.map-hint{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);z-index:30;background:rgba(26,26,26,.82);color:#fff;font-size:12px;padding:6px 13px;border-radius:999px;pointer-events:none}
|
|
420
|
+
.leaflet-container{font:inherit}
|
|
421
|
+
`;
|
|
422
|
+
const MAP_MARKUP = `<div id=mapWrap><div id=map3dd></div><div id=svPano></div><div class=map-search id=mapSearch style="display:none"><input id=mapQ placeholder="Rechercher un lieu, une adresse…" autocomplete=off><div class=map-res id=mapRes></div></div><button class=map-type id=mapType style="display:none">🛰 Satellite</button><button class=map-sv id=mapSV style="display:none">Passer en Street View</button><button class=map-tomap id=mapToMap style="display:none">← Revenir à la carte</button><button class=map-back id=mapBack style="display:none">← Revenir au document</button><div class=map-hint id=mapHint></div></div>`;
|
|
423
|
+
const MAP_JS = `
|
|
424
|
+
var Map3DD=(function(){
|
|
425
|
+
var map=null,marker=null,leafletLoading=false,isPres=false,persist=null,_bcT=0,_psT=0,mapType='roadmap';
|
|
426
|
+
var pano=null,gLoading=false,_svBcT=0;
|
|
427
|
+
var useG=!!GMAPS_KEY; // carte de base = Google Maps si une clé est fournie, sinon repli OpenStreetMap (Leaflet)
|
|
428
|
+
function loadLeaflet(cb){ if(window.L){cb();return;} var iv=setInterval(function(){if(window.L){clearInterval(iv);cb();}},80);
|
|
429
|
+
if(leafletLoading)return; leafletLoading=true;
|
|
430
|
+
var css=document.createElement('link');css.rel='stylesheet';css.href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';document.head.appendChild(css);
|
|
431
|
+
var s=document.createElement('script');s.src='https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';document.body.appendChild(s); }
|
|
432
|
+
// Google Maps JS chargé à la demande, seulement si une clé est fournie (GMAPS_KEY).
|
|
433
|
+
function loadGoogle(cb){ if(window.google&&window.google.maps){cb();return;} if(!GMAPS_KEY)return; var iv=setInterval(function(){if(window.google&&window.google.maps){clearInterval(iv);cb();}},120);
|
|
434
|
+
if(gLoading)return; gLoading=true;
|
|
435
|
+
var s=document.createElement('script');s.async=true;s.src='https://maps.googleapis.com/maps/api/js?key='+encodeURIComponent(GMAPS_KEY)+'&v=weekly&loading=async';document.body.appendChild(s); }
|
|
436
|
+
function loadBase(cb){ if(useG)loadGoogle(cb); else loadLeaflet(cb); }
|
|
437
|
+
function ensureMap(center,zoom){ if(map)return;
|
|
438
|
+
if(useG){ var el=document.getElementById('map3dd'); if(!el||!window.google)return;
|
|
439
|
+
var o=isPres?{center:{lat:center[0],lng:center[1]},zoom:zoom,mapTypeId:mapType,mapTypeControl:false,streetViewControl:true,fullscreenControl:false,clickableIcons:false,gestureHandling:'greedy'}
|
|
440
|
+
:{center:{lat:center[0],lng:center[1]},zoom:zoom,mapTypeId:mapType,disableDefaultUI:true,gestureHandling:'none',keyboardShortcuts:false,clickableIcons:false,zoomControl:false};
|
|
441
|
+
map=new google.maps.Map(el,o);
|
|
442
|
+
if(isPres){ map.addListener('center_changed',broadcast); map.addListener('zoom_changed',broadcast); map.addListener('idle',schedPersist); map.addListener('maptypeid_changed',function(){ mapType=map.getMapTypeId(); updateTypeBtn(); broadcast(); schedPersist(); });
|
|
443
|
+
// Pegman (bonhomme jaune) : quand on le dépose, on récupère le point et on entre dans NOTRE Street View synchronisé.
|
|
444
|
+
try{ var svp=map.getStreetView(); svp.addListener('visible_changed',function(){ if(svp.getVisible()){ var p=svp.getPosition(); svp.setVisible(false); if(p)goSV([p.lat(),p.lng()]); } }); }catch(e){}
|
|
445
|
+
}
|
|
446
|
+
} else {
|
|
447
|
+
map=L.map('map3dd',{zoomControl:isPres,attributionControl:true,dragging:isPres,scrollWheelZoom:isPres,doubleClickZoom:isPres,boxZoom:isPres,keyboard:isPres,touchZoom:isPres}).setView(center,zoom);
|
|
448
|
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OpenStreetMap'}).addTo(map);
|
|
449
|
+
if(isPres){ map.on('move',broadcast); map.on('moveend',schedPersist); }
|
|
450
|
+
} }
|
|
451
|
+
function setMarker(ll){ if(useG){ if(!ll){ if(marker){marker.setMap(null);marker=null;} return; } var g={lat:ll[0],lng:ll[1]}; if(marker){marker.setPosition(g);} else if(map){marker=new google.maps.Marker({position:g,map:map});} }
|
|
452
|
+
else { if(!ll){ if(marker&&map){map.removeLayer(marker);} marker=null; return; } if(marker){marker.setLatLng(ll);} else if(map){marker=L.marker(ll).addTo(map);} } }
|
|
453
|
+
function state(){ if(!map)return null; if(useG){ var c=map.getCenter(); return {kind:'map',center:[c.lat(),c.lng()],zoom:map.getZoom(),marker:marker?[marker.getPosition().lat(),marker.getPosition().lng()]:null,mapType:map.getMapTypeId()}; }
|
|
454
|
+
var c2=map.getCenter(); return {kind:'map',center:[c2.lat,c2.lng],zoom:map.getZoom(),marker:marker?[marker.getLatLng().lat,marker.getLatLng().lng]:null}; }
|
|
455
|
+
function setCenterZoom(ll,z){ if(useG){ map.setCenter({lat:ll[0],lng:ll[1]}); map.setZoom(z); } else { map.setView(ll,z); } }
|
|
456
|
+
function broadcast(){ var n=Date.now(); if(n-_bcT<100||!map)return; _bcT=n; var s=state(); if(s){try{ if(window.Live) Live.sendMap(s); }catch(e){}} }
|
|
457
|
+
function schedPersist(){ clearTimeout(_psT); _psT=setTimeout(function(){ if(map&&persist)persist(state()); },700); }
|
|
458
|
+
function enter(content,presenter,persistFn){ isPres=!!presenter; if(persistFn)persist=persistFn;
|
|
459
|
+
if(content&&content.mapType)mapType=content.mapType;
|
|
460
|
+
var wrap=document.getElementById('mapWrap'); var on=wrap&&wrap.classList.contains('on');
|
|
461
|
+
if(wrap){wrap.classList.add('on');wrap.classList.remove('sv');}
|
|
462
|
+
var sb2=document.getElementById('mapSearch'); if(sb2)sb2.style.display=isPres?'block':'none';
|
|
463
|
+
var tb=document.getElementById('mapType'); if(tb)tb.style.display=(isPres&&useG)?'block':'none';
|
|
464
|
+
var svb=document.getElementById('mapSV'); if(svb)svb.style.display=(isPres&&GMAPS_KEY)?'block':'none';
|
|
465
|
+
var tm=document.getElementById('mapToMap'); if(tm)tm.style.display='none';
|
|
466
|
+
var bk=document.getElementById('mapBack'); if(bk)bk.style.display=isPres?'block':'none';
|
|
467
|
+
var hint=document.getElementById('mapHint'); if(hint)hint.textContent=isPres?'':'Vue du présentateur — en direct';
|
|
468
|
+
if(isPres)wireControls(); // TOUJOURS câbler (recherche/satellite/SV) — même si la carte existe déjà, sinon la recherche ne marche pas.
|
|
469
|
+
var center=(content&&content.center)||[46.6,2.5],zoom=(content&&content.zoom)||6;
|
|
470
|
+
if(map&&on){ mapApply(content); if(isPres)updateTypeBtn(); return; }
|
|
471
|
+
loadBase(function(){ ensureMap(center,zoom); if(useG){ setTimeout(function(){ if(map&&window.google)google.maps.event.trigger(map,'resize'); },160); } else { [60,300,700,1400].forEach(function(d){setTimeout(function(){if(map)map.invalidateSize();},d);}); } mapApply(content); if(isPres)updateTypeBtn(); }); }
|
|
472
|
+
function exit(){ var wrap=document.getElementById('mapWrap'); if(wrap){wrap.classList.remove('on');wrap.classList.remove('sv');} }
|
|
473
|
+
function mapApply(p){ if(!map||!p)return; if(useG){ if(p.center)map.setCenter({lat:p.center[0],lng:p.center[1]}); if(typeof p.zoom!=='undefined')map.setZoom(p.zoom); if(p.mapType&&p.mapType!==map.getMapTypeId())map.setMapTypeId(p.mapType); if(typeof p.marker!=='undefined')setMarker(p.marker); }
|
|
474
|
+
else { if(p.center)map.setView(p.center,p.zoom||map.getZoom(),{animate:false}); if(typeof p.marker!=='undefined')setMarker(p.marker); } }
|
|
475
|
+
// Routeur des broadcasts live : carte OU street view selon le kind.
|
|
476
|
+
function apply(p){ if(!p)return; if(p.kind==='streetview')svApply(p); else mapApply(p); }
|
|
477
|
+
// Bascule plan / satellite / hybride (Google) : diffusée à l'audience.
|
|
478
|
+
function cycleType(){ if(!map||!useG)return; var next=Player.presentation.cycleMapType(map.getMapTypeId()); map.setMapTypeId(next); mapType=next; updateTypeBtn(); broadcast(); schedPersist(); }
|
|
479
|
+
function updateTypeBtn(){ var b=document.getElementById('mapType'); if(!b)return; var t=(map&&useG)?map.getMapTypeId():'roadmap'; b.textContent=Player.presentation.mapTypeLabel(t); }
|
|
480
|
+
function wireControls(){ var q=document.getElementById('mapQ'),res=document.getElementById('mapRes');
|
|
481
|
+
if(q&&!q._w){q._w=1; q.addEventListener('keydown',function(e){ if(e.key==='Enter'){e.preventDefault();clearTimeout(q._st);doSearch(q.value);} });
|
|
482
|
+
// Suggestions pendant la frappe (debounce) — dès 3 caractères, sans attendre Entrée.
|
|
483
|
+
q.addEventListener('input',function(){ clearTimeout(q._st); var v=q.value; if(v.trim().length<3){ if(res)res.innerHTML=''; return; } q._st=setTimeout(function(){ doSearch(v); },260); });
|
|
484
|
+
if(res)res.addEventListener('click',function(e){var b=e.target.closest?e.target.closest('button'):null;if(!b||!map)return;var lat=+b.getAttribute('data-lat'),lng=+b.getAttribute('data-lng');setCenterZoom([lat,lng],16);setMarker([lat,lng]);res.innerHTML='';q.value=b.textContent;broadcast();schedPersist();}); }
|
|
485
|
+
var tb=document.getElementById('mapType'); if(tb&&!tb._w){tb._w=1;tb.addEventListener('click',cycleType);}
|
|
486
|
+
var svb=document.getElementById('mapSV'); if(svb&&!svb._w){svb._w=1;svb.addEventListener('click',function(){ if(!map)return; var c=useG?[map.getCenter().lat(),map.getCenter().lng()]:[map.getCenter().lat,map.getCenter().lng]; goSV(c); });}
|
|
487
|
+
var tm=document.getElementById('mapToMap'); if(tm&&!tm._w){tm._w=1;tm.addEventListener('click',toMap);} }
|
|
488
|
+
function doSearch(text){ text=(text||'').trim(); var res=document.getElementById('mapRes'); if(!text||!res)return; res.innerHTML='<div style="padding:9px 14px;color:#888;font-size:12px">Recherche…</div>';
|
|
489
|
+
fetch('https://nominatim.openstreetmap.org/search?format=json&limit=5&q='+encodeURIComponent(text),{headers:{Accept:'application/json'}}).then(function(r){return r.json();}).then(function(l){ if(!l||!l.length){res.innerHTML='<div style="padding:9px 14px;color:#888;font-size:12px">Aucun résultat.</div>';return;} res.innerHTML=l.map(function(o){return '<button data-lat="'+o.lat+'" data-lng="'+o.lon+'">'+String(o.display_name||'').replace(/</g,'<')+'</button>';}).join(''); }).catch(function(){res.innerHTML='<div style="padding:9px 14px;color:#c0392b;font-size:12px">Recherche indisponible.</div>';}); }
|
|
490
|
+
// ── Street View (Google) ─────────────────────────────────────────────────────────────────────────
|
|
491
|
+
function tempHint(t){ var h=document.getElementById('mapHint'); if(h){h.textContent=t; setTimeout(function(){ if(h.textContent===t)h.textContent=(isPres?'':'Vue du présentateur — en direct'); },2600);} }
|
|
492
|
+
// Le présentateur passe en Street View depuis le centre de la carte : on cherche le panorama le plus proche.
|
|
493
|
+
function goSV(center){ if(!GMAPS_KEY){tempHint('Street View indisponible.');return;} tempHint('Recherche Street View…');
|
|
494
|
+
loadGoogle(function(){ try{ new google.maps.StreetViewService().getPanorama({location:{lat:center[0],lng:center[1]},radius:80},function(data,status){ if(status==='OK'&&data&&data.location){ var ll=data.location.latLng; var content={kind:'streetview',position:[ll.lat(),ll.lng()],pov:{heading:0,pitch:0},zoom:1}; if(persist)persist(content); enterSV(content,true,persist); } else { tempHint('Pas de Street View à cet endroit.'); } }); }catch(e){ tempHint('Street View indisponible.'); } }); }
|
|
495
|
+
function svState(){ if(!pano)return null; var p=pano.getPosition(),v=pano.getPov(); if(!p)return null; return {kind:'streetview',position:[p.lat(),p.lng()],pov:{heading:v.heading,pitch:v.pitch},zoom:pano.getZoom()}; }
|
|
496
|
+
function svBcast(){ var n=Date.now(); if(n-_svBcT<120)return; _svBcT=n; var s=svState(); if(!s)return; try{ if(window.Live)Live.sendMap(s); }catch(e){} clearTimeout(_psT); _psT=setTimeout(function(){ if(persist&&pano)persist(svState()); },800); }
|
|
497
|
+
function svApply(p){ if(!pano||!p)return; try{ if(p.position)pano.setPosition({lat:p.position[0],lng:p.position[1]}); if(p.pov)pano.setPov({heading:p.pov.heading||0,pitch:p.pov.pitch||0}); if(typeof p.zoom!=='undefined')pano.setZoom(p.zoom); }catch(e){} }
|
|
498
|
+
function ensurePano(content){ var el=document.getElementById('svPano'); if(!el||!window.google||!window.google.maps)return;
|
|
499
|
+
var pos=content&&content.position?{lat:content.position[0],lng:content.position[1]}:{lat:48.8584,lng:2.2945};
|
|
500
|
+
var pov=content&&content.pov?content.pov:{heading:0,pitch:0}, zoom=(content&&content.zoom)||1;
|
|
501
|
+
if(pano){ return; }
|
|
502
|
+
var opts=isPres
|
|
503
|
+
?{position:pos,pov:pov,zoom:zoom,addressControl:false,fullscreenControl:false,motionTracking:false,motionTrackingControl:false,showRoadLabels:true}
|
|
504
|
+
:{position:pos,pov:pov,zoom:zoom,disableDefaultUI:true,clickToGo:false,scrollwheel:false,linksControl:false,panControl:false,zoomControl:false,addressControl:false,fullscreenControl:false,motionTracking:false,motionTrackingControl:false,showRoadLabels:false};
|
|
505
|
+
pano=new google.maps.StreetViewPanorama(el,opts);
|
|
506
|
+
if(isPres){ pano.addListener('position_changed',svBcast); pano.addListener('pov_changed',svBcast); pano.addListener('zoom_changed',svBcast); } }
|
|
507
|
+
function enterSV(content,presenter,persistFn){ isPres=!!presenter; if(persistFn)persist=persistFn;
|
|
508
|
+
var wrap=document.getElementById('mapWrap'); if(wrap){wrap.classList.add('on');wrap.classList.add('sv');}
|
|
509
|
+
var tm=document.getElementById('mapToMap'); if(tm)tm.style.display=isPres?'block':'none';
|
|
510
|
+
var bk=document.getElementById('mapBack'); if(bk)bk.style.display=isPres?'block':'none';
|
|
511
|
+
var hint=document.getElementById('mapHint'); if(hint)hint.textContent=isPres?'':'Vue du présentateur — Street View en direct';
|
|
512
|
+
loadGoogle(function(){ ensurePano(content); svApply(content); if(isPres)wireControls(); }); }
|
|
513
|
+
// Retour à la carte (présentateur) : on repasse en mode carte et on persiste l'état carte.
|
|
514
|
+
function toMap(){ var wrap=document.getElementById('mapWrap'); if(wrap)wrap.classList.remove('sv'); var st=map?state():Player.presentation.initialMapContent(); enter(st,true,persist); if(persist)persist(st); }
|
|
515
|
+
return {enter:enter,enterSV:enterSV,exit:exit,apply:apply,state:function(){return map?state():null;}};
|
|
516
|
+
})();`;
|
|
517
|
+
|
|
518
|
+
const BOT_CSS = `
|
|
519
|
+
/* Doc + chat côte à côte (ces styles ne vivent sinon que dans LIVE_CSS, réservé au mode présentation). */
|
|
520
|
+
.lrow{flex:1;display:flex;min-height:0;position:relative}
|
|
521
|
+
.lmain{flex:1;min-width:0;display:flex;flex-direction:column;position:relative}
|
|
522
|
+
.botc{flex:none;width:360px;max-width:40vw;display:flex;flex-direction:column;background:#faf8f4;color:#1a1a1a;border-left:1px solid #0002}
|
|
523
|
+
.botc.min{display:none}
|
|
524
|
+
.botc-h{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid #0001;background:#fff}
|
|
525
|
+
.botc-av{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;background:var(--bacc,#15130f);color:#fff;font-size:14px;flex:none;font-weight:800;overflow:hidden}
|
|
526
|
+
.botc-av img,.botc-fab img{width:100%;height:100%;object-fit:cover;border-radius:50%}
|
|
527
|
+
/* Rangée bot : mini-avatar du profil + bulle (pattern messagerie moderne). L'historique restauré est atténué. */
|
|
528
|
+
.botc-brow{display:flex;gap:7px;align-items:flex-end}
|
|
529
|
+
.botc-brow .botc-msg.bot{max-width:100%}
|
|
530
|
+
.botc-mav{flex:none;width:22px;height:22px;border-radius:50%;background:var(--bacc,#15130f);color:#fff;font-size:10px;font-weight:800;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
|
531
|
+
.botc-mav img{width:100%;height:100%;object-fit:cover}
|
|
532
|
+
.botc-brow.old,.botc-msg.old{opacity:.55}
|
|
533
|
+
.botc-div{display:flex;align-items:center;gap:10px;color:#a89f90;font-size:10px;letter-spacing:.08em;text-transform:uppercase;margin:4px 0;white-space:nowrap}
|
|
534
|
+
.botc-div::before,.botc-div::after{content:"";flex:1;height:1px;background:#e5dfd4}
|
|
535
|
+
.botc-h b{font-size:13.5px;display:block;line-height:1.2}
|
|
536
|
+
.botc-sub{font-size:11px;color:#8a857c}
|
|
537
|
+
.botc-min{margin-left:auto;border:0;background:#efece7;color:#5a554d;width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:17px;line-height:0}
|
|
538
|
+
.botc-min:hover{background:#e2e0da}
|
|
539
|
+
.botc-voice{margin-left:auto;border:0;background:#efece7;color:#5a554d;width:28px;height:28px;border-radius:8px;cursor:pointer;line-height:0;display:inline-flex;align-items:center;justify-content:center}
|
|
540
|
+
.botc-voice svg{width:16px;height:16px}
|
|
541
|
+
.botc-voice+.botc-min{margin-left:6px}
|
|
542
|
+
.botc-voice:hover{background:#e2e0da}
|
|
543
|
+
.botc-voice.on{background:var(--bacc,#15130f);color:#fff}
|
|
544
|
+
.botc-voice.on.playing{animation:botcVoicePulse 1.4s ease-in-out infinite}
|
|
545
|
+
@keyframes botcVoicePulse{0%,100%{box-shadow:0 0 0 0 rgba(0,0,0,.18)}50%{box-shadow:0 0 0 5px rgba(0,0,0,0)}}
|
|
546
|
+
#botpVoice.on{background:var(--bacc,#15130f);color:#fff}
|
|
547
|
+
.botc-msgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:9px}
|
|
548
|
+
/* CRITIQUE : dans une colonne flex qui défile, les enfants se COMPRESSENT (flex-shrink:1 par défaut) avant
|
|
549
|
+
que le scroll ne joue → bulles écrasées/chevauchées, vignettes rognées en bandeau. Interdit. */
|
|
550
|
+
.botc-msgs>*{flex:none}
|
|
551
|
+
.botc-msg{max-width:88%;padding:9px 13px;border-radius:15px;font-size:13.5px;line-height:1.45;white-space:pre-wrap;word-break:break-word;animation:botmsg .26s cubic-bezier(.22,1,.36,1)}
|
|
552
|
+
.botc-msg.bot{align-self:flex-start;background:#fff;border:1px solid #ece8e1;border-bottom-left-radius:5px}
|
|
553
|
+
.botc-msg.user{align-self:flex-end;background:#15130f;color:#fff;border-bottom-right-radius:5px}
|
|
554
|
+
@keyframes botmsg{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}
|
|
555
|
+
.botc-cursor{display:inline-block;width:2px;height:1em;background:#15130f;margin-left:1px;vertical-align:-2px;animation:botcur .8s steps(1) infinite}
|
|
556
|
+
@keyframes botcur{0%,50%{opacity:1}51%,100%{opacity:0}}
|
|
557
|
+
.botc-typing{align-self:flex-start;display:flex;gap:5px;padding:11px 14px;background:#fff;border:1px solid #ece8e1;border-radius:15px;border-bottom-left-radius:5px}
|
|
558
|
+
.botc-typing i{width:7px;height:7px;border-radius:50%;background:#c0b9ac;animation:botdot 1.15s infinite}
|
|
559
|
+
.botc-typing i:nth-child(2){animation-delay:.16s}
|
|
560
|
+
.botc-typing i:nth-child(3){animation-delay:.32s}
|
|
561
|
+
@keyframes botdot{0%,62%,100%{transform:translateY(0);opacity:.45}31%{transform:translateY(-5px);opacity:1}}
|
|
562
|
+
/* Mode « présentation guidée » : le prospect ne défile pas, le bot pilote (overflow programmatique OK). */
|
|
563
|
+
body.botlock .scroll{overflow:hidden}
|
|
564
|
+
body.botlock .scroll{scrollbar-width:none}
|
|
565
|
+
body.botlock .scroll::-webkit-scrollbar{display:none}
|
|
566
|
+
/* Mode « une seule page » : une page plein cadre, centrée, aucune page suivante ne dépasse. Le bot (ou les
|
|
567
|
+
flèches) tournent les pages. On sort de ce mode dès que le prospect passe en découverte autonome. */
|
|
568
|
+
body.onepage .scroll{overflow:hidden}
|
|
569
|
+
body.onepage #pages{height:100%;display:flex;align-items:center;justify-content:center;padding:0}
|
|
570
|
+
|
|
571
|
+
body.onepage #pages .page{display:none;margin:0}
|
|
572
|
+
body.onepage #pages .page.cur{display:block;box-shadow:0 6px 34px rgba(0,0,0,.16)}
|
|
573
|
+
/* Transition de page sobre en mode guidé/lecture (glissé directionnel ~0,28 s). Exclut le player mobile
|
|
574
|
+
(rythme rapide). Activable/désactivable via .botanim (CFG.botAnim, défaut ON — param profil futur). */
|
|
575
|
+
@keyframes pgInF{from{opacity:.25;transform:translateX(22px)}to{opacity:1;transform:none}}
|
|
576
|
+
@keyframes pgInB{from{opacity:.25;transform:translateX(-22px)}to{opacity:1;transform:none}}
|
|
577
|
+
body.botanim.onepage:not(.botplayer) #pages .page.cur{animation:pgInF .28s cubic-bezier(.22,1,.36,1)}
|
|
578
|
+
body.botanim.onepage.pgback:not(.botplayer) #pages .page.cur{animation:pgInB .28s cubic-bezier(.22,1,.36,1)}
|
|
579
|
+
@media(prefers-reduced-motion:reduce){ body.botanim.onepage #pages .page.cur{animation:none} }
|
|
580
|
+
body.onepage .textLayer{display:none}
|
|
581
|
+
.op-arrow{display:none;position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;border-radius:50%;border:0;background:#15130fcc;color:#fff;font-size:24px;line-height:0;cursor:pointer;z-index:12;align-items:center;justify-content:center;box-shadow:0 4px 16px rgba(0,0,0,.28);transition:background .15s,opacity .15s}
|
|
582
|
+
.op-arrow:hover{background:#15130f}
|
|
583
|
+
.op-arrow:disabled{opacity:.28;cursor:default}
|
|
584
|
+
.dkov{display:none;position:absolute;inset:0;z-index:14;background:rgba(10,9,7,.52);backdrop-filter:blur(3px);align-items:center;justify-content:center;cursor:pointer}
|
|
585
|
+
body.deskpaused .dkov{display:flex}
|
|
586
|
+
.dkov-card{display:flex;flex-direction:column;align-items:center;gap:16px;cursor:default;animation:dkovin .28s cubic-bezier(.22,1,.36,1)}
|
|
587
|
+
@keyframes dkovin{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
|
|
588
|
+
.dkov-big{width:78px;height:78px;border-radius:50%;border:0;background:#fff;color:#15130f;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 14px 44px rgba(0,0,0,.5);transition:transform .15s}
|
|
589
|
+
.dkov-big:hover{transform:scale(1.12);box-shadow:0 18px 54px rgba(0,0,0,.6),0 0 0 7px rgba(255,255,255,.28)}
|
|
590
|
+
.dkov-big svg{width:30px;height:30px}
|
|
591
|
+
.dkov-opts{display:flex;flex-direction:column;gap:9px;min-width:330px}
|
|
592
|
+
.dkov-opts button{position:relative;overflow:hidden;display:flex;align-items:center;gap:13px;border:0;border-radius:12px;background:rgba(250,248,244,.94);color:#1a1a1a;font:inherit;font-size:14px;font-weight:600;padding:12px 18px;cursor:pointer;text-align:left;transition:background .18s,transform .28s cubic-bezier(.34,1.56,.64,1),box-shadow .28s}
|
|
593
|
+
.dkov-opts button:hover{background:#fff;transform:translateY(-2px) scale(1.015);box-shadow:0 10px 26px rgba(0,0,0,.22)}
|
|
594
|
+
.dkov-opts button:active{transform:translateY(0) scale(.985);transition-duration:.08s}
|
|
595
|
+
/* Reflet balayé : une lame de lumière traverse le bouton au survol (désactivé si mouvement réduit). */
|
|
596
|
+
.dkov-opts button::after{content:"";position:absolute;top:0;bottom:0;left:-70%;width:46%;background:linear-gradient(105deg,transparent,rgba(255,255,255,.5),transparent);transform:skewX(-18deg);opacity:0;pointer-events:none}
|
|
597
|
+
.dkov-opts button:hover::after{animation:dk-sheen .7s ease forwards}
|
|
598
|
+
@keyframes dk-sheen{from{left:-70%;opacity:1}to{left:120%;opacity:0}}
|
|
599
|
+
.dkov-opts button svg{width:19px;height:19px;flex:none;opacity:.85;transition:transform .28s cubic-bezier(.34,1.56,.64,1),opacity .18s}
|
|
600
|
+
.dkov-opts button:hover svg{transform:translateX(3px) scale(1.12);opacity:1}
|
|
601
|
+
.dkov-opts button.primary{background:#15130f;color:#fff}
|
|
602
|
+
.dkov-opts button.primary:hover{background:#000;box-shadow:0 12px 30px rgba(0,0,0,.4)}
|
|
603
|
+
.dkov-opts button.primary::after{background:linear-gradient(105deg,transparent,rgba(255,255,255,.22),transparent)}
|
|
604
|
+
@media (prefers-reduced-motion: reduce){ .dkov-opts button,.dkov-opts button svg{transition:none} .dkov-opts button:hover{transform:none} .dkov-opts button:hover::after{animation:none} }
|
|
605
|
+
.dkov-opts button.ghost{background:none;border:1.5px solid rgba(255,255,255,.38);color:#f1efe9;font-weight:600;font-size:13px;justify-content:center;padding:9px 18px;margin-top:2px}
|
|
606
|
+
.dkov-opts button.ghost svg{width:16px;height:16px;opacity:.7}
|
|
607
|
+
.dkov-opts button.ghost:hover{background:rgba(255,255,255,.12);transform:none}
|
|
608
|
+
.kw{opacity:.45;display:inline-block;border-radius:5px;transition:opacity .18s,background-color .18s,color .18s,transform .18s}
|
|
609
|
+
.kw.on{opacity:1}
|
|
610
|
+
.kw.cur{opacity:1;background:color-mix(in srgb,currentColor 15%,transparent);box-shadow:0 1.8px 0 0 currentColor;padding:1px 3px;margin:-1px -3px}
|
|
611
|
+
/* FOCUS (façon Captions) : le mot courant saute aux yeux — pilule accent pleine, texte inversé, léger zoom */
|
|
612
|
+
.ks-focus .kw{opacity:.35}
|
|
613
|
+
.ks-focus .kw.on{opacity:.92}
|
|
614
|
+
.ks-focus .kw.cur{opacity:1;background:var(--bacc,#15130f);color:#fff;box-shadow:none;padding:1px 6px;margin:-1px -3px;border-radius:7px;transform:scale(1.07)}
|
|
615
|
+
/* ENCRE : les mots lus se teintent à la couleur de l'agent (remplissage progressif) */
|
|
616
|
+
.ks-fill .kw{opacity:.38}
|
|
617
|
+
.ks-fill .kw.on{opacity:1;color:var(--bacc,#15130f)}
|
|
618
|
+
.ks-fill .kw.cur{opacity:1;color:var(--bacc,#15130f);background:none;box-shadow:0 1.8px 0 0 currentColor;padding:0;margin:0}
|
|
619
|
+
/* SOULIGNÉ : sobre — texte toujours lisible, seul un trait accent suit la voix */
|
|
620
|
+
.ks-underline .kw{opacity:.86}
|
|
621
|
+
.ks-underline .kw.on{opacity:1}
|
|
622
|
+
.ks-underline .kw.cur{opacity:1;background:none;box-shadow:0 2px 0 0 var(--bacc,#15130f);padding:0;margin:0}
|
|
623
|
+
.botc-kstyle{display:none;margin-left:6px;border:0;background:#efece7;color:#5a554d;width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:12px;font-weight:800;align-items:center;justify-content:center;line-height:0}
|
|
624
|
+
body.deskpresent .botc-kstyle{display:inline-flex}
|
|
625
|
+
.botc-kstyle:hover{background:#e2e0da}
|
|
626
|
+
.botc-cc{display:none;margin-left:6px;border:0;background:#efece7;color:#5a554d;width:28px;height:28px;border-radius:8px;cursor:pointer;line-height:0;align-items:center;justify-content:center}
|
|
627
|
+
body.deskpresent .botc-cc{display:inline-flex}
|
|
628
|
+
.botc-cc svg{width:15px;height:15px}
|
|
629
|
+
.botc-cc:hover{background:#e2e0da}
|
|
630
|
+
body.deskcap .botc-cc{background:var(--bacc,#15130f);color:#fff}
|
|
631
|
+
/* Présentateur vidéo — WEBCAM flottante desktop (façon streamer/visio : le doc reste le héros). */
|
|
632
|
+
.vpan{display:none;position:fixed;bottom:132px;width:min(300px,24vw);aspect-ratio:1/1;z-index:14;background:#0d0c0a;overflow:hidden;border-radius:18px;box-shadow:0 18px 50px rgba(0,0,0,.45),0 0 0 1px rgba(255,255,255,.10);transition:opacity .3s}
|
|
633
|
+
body.vside.deskpresent .vpan{display:block}
|
|
634
|
+
body.vside-r .vpan{right:24px} body.vside-l .vpan{left:24px}
|
|
635
|
+
.vpan img,.vpan video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
|
636
|
+
.vpan video{display:none} .vpan.playing video{display:block}
|
|
637
|
+
body.vside .dcap-av{display:none} /* la webcam remplace la pastille du bandeau */
|
|
638
|
+
body.vside.deskpaused .vpan{opacity:.25} /* le hub de pause reprend la scène */
|
|
639
|
+
/* Mobile ÉCRAN PARTAGÉ v2 : doc en haut, l'agent en PLEIN CADRE en bas, sous-titres pleine largeur. */
|
|
640
|
+
.vpanm{display:none;position:fixed;left:0;right:0;bottom:0;height:38vh;z-index:31;background:#0d0c0a;overflow:hidden}
|
|
641
|
+
body.vsplit .vpanm{display:block}
|
|
642
|
+
.vpanm img,.vpanm video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center 22%}
|
|
643
|
+
.vpanm video{display:none} .vpanm.playing video{display:block}
|
|
644
|
+
.vpanm::after{content:"";position:absolute;left:0;right:0;bottom:0;height:52%;background:linear-gradient(180deg,transparent,rgba(0,0,0,.66));pointer-events:none}
|
|
645
|
+
body.vsplit .botp-cap{left:10px;right:10px;bottom:10px;margin:0;border-radius:0;background:none;backdrop-filter:none;-webkit-backdrop-filter:none;box-shadow:none;padding:0;z-index:32}
|
|
646
|
+
body.vsplit .pcap-k{font-size:28px}
|
|
647
|
+
body.vsplit .botp-ctl{bottom:calc(38vh + 10px)}
|
|
648
|
+
body.vsplit .botp-prog i{}
|
|
649
|
+
body.vsplit .botp-cap:empty{display:none}
|
|
650
|
+
.botw-note{font-size:11.5px;color:#8a867e;margin:2px 2px 0;text-align:center}
|
|
651
|
+
.langov{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(10,9,7,.26);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);opacity:0;pointer-events:none;transition:opacity .25s}
|
|
652
|
+
.langov.on{opacity:1;pointer-events:auto}
|
|
653
|
+
.langov-card{display:flex;align-items:center;gap:12px;padding:14px 24px;border-radius:999px;background:rgba(30,27,22,.9);backdrop-filter:blur(16px) saturate(1.2);-webkit-backdrop-filter:blur(16px) saturate(1.2);color:#fff;font-weight:650;font-size:14.5px;letter-spacing:.01em;box-shadow:0 18px 50px rgba(0,0,0,.35);border:1px solid rgba(255,255,255,.14)}
|
|
654
|
+
.langov-spin{width:16px;height:16px;border-radius:50%;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;animation:lo-rot .7s linear infinite;flex:none}
|
|
655
|
+
@keyframes lo-rot{to{transform:rotate(360deg)}}
|
|
656
|
+
body.light .langov{background:rgba(247,245,241,.32)}
|
|
657
|
+
body.light .langov-card{background:rgba(247,245,241,.94);color:#15130f;border-color:rgba(0,0,0,.08);box-shadow:0 18px 50px rgba(0,0,0,.16)}
|
|
658
|
+
body.light .langov-spin{border-color:rgba(0,0,0,.18);border-top-color:#15130f}
|
|
659
|
+
.dcap{display:none;position:absolute;left:50%;bottom:26px;transform:translateX(-50%);width:min(1080px,calc(100% - 190px));z-index:13;align-items:center;gap:15px}
|
|
660
|
+
body.deskcap.deskpresent .dcap{display:flex}
|
|
661
|
+
.dcap-av{flex:none;width:58px;height:58px;border-radius:50%;overflow:hidden;background:#e6e2db;display:flex;align-items:center;justify-content:center;font-size:21px;font-weight:800;color:#555;box-shadow:0 0 0 3px rgba(255,255,255,.55),0 6px 18px rgba(0,0,0,.4);position:relative;transition:width .3s ease,height .3s ease}
|
|
662
|
+
body.clipon .dcap-av{width:114px;height:114px}
|
|
663
|
+
.dcap-av video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:2;background:#e6e2db}
|
|
664
|
+
.dcap-av img{width:100%;height:100%;object-fit:cover}
|
|
665
|
+
.dcap-body{--bacc:#fff;flex:1;min-width:0;background:rgba(12,10,8,.86);backdrop-filter:blur(8px);color:#fff;border-radius:20px;padding:16px 26px;box-shadow:0 18px 52px rgba(0,0,0,.45);font-size:clamp(20px,2vw,28px);line-height:1.45;font-weight:700}
|
|
666
|
+
#dcapT{text-align:left;min-height:1.45em}
|
|
667
|
+
#dcapT .kw{animation:dcapw .18s ease;transition:opacity .15s} /* pas de transition couleur/fond dans le bandeau (états fantômes) */
|
|
668
|
+
@keyframes dcapw{from{opacity:0;transform:translateY(5px)}to{}}
|
|
669
|
+
/* FOCUS dans le bandeau : pilule INVERSÉE lisible quel que soit le thème */
|
|
670
|
+
.dcap-body .ks-focus .kw.cur{background:#fff;color:#15130f}
|
|
671
|
+
body.light .dcap-body .ks-focus .kw.cur{background:#15130f;color:#fff}
|
|
672
|
+
body.light .dcap-body{background:rgba(255,255,255,.95);color:#15130f;--bacc:#15130f;box-shadow:0 18px 52px rgba(0,0,0,.16)}
|
|
673
|
+
body.botplayer .dcap,body.botplayer .dkov{display:none !important} /* le PLAYER mobile a ses propres surfaces ; une fenêtre desktop RÉTRÉCIE garde bandeau + hub */
|
|
674
|
+
.rateov{display:none;position:absolute;inset:0;z-index:16;background:rgba(10,9,7,.48);backdrop-filter:blur(4px);align-items:center;justify-content:center}
|
|
675
|
+
.rateov.on{display:flex}
|
|
676
|
+
.rate-card{position:relative;background:#faf8f4;color:#1a1a1a;border-radius:20px;padding:28px 42px 24px;text-align:center;box-shadow:0 24px 70px rgba(0,0,0,.5);animation:dkovin .28s cubic-bezier(.22,1,.36,1)}
|
|
677
|
+
.rate-card b{font-size:19px}
|
|
678
|
+
.rate-card p{margin:6px 0 16px;font-size:13.5px;color:#6b665e}
|
|
679
|
+
.rate-x{position:absolute;top:10px;right:10px;border:0;background:none;color:#a39d92;cursor:pointer;line-height:0;padding:6px}
|
|
680
|
+
.rate-x svg{width:15px;height:15px}
|
|
681
|
+
.rate-stars{display:flex;gap:9px;justify-content:center}
|
|
682
|
+
.rate-stars button{border:0;background:none;cursor:pointer;padding:2px;line-height:0;transition:transform .12s}
|
|
683
|
+
.rate-stars button:hover{transform:scale(1.18)}
|
|
684
|
+
.rate-stars svg{width:42px;height:42px;fill:none;stroke:#cfc8ba;stroke-width:1.5;transition:fill .12s,stroke .12s}
|
|
685
|
+
.rate-stars button.on svg{fill:#f6b301;stroke:#e3a300}
|
|
686
|
+
.rate-thx{display:none;font-size:15.5px;font-weight:700;padding:10px 0 4px}
|
|
687
|
+
.rateov.done .rate-stars,.rateov.done .rate-card p{display:none}
|
|
688
|
+
.rateov.done .rate-thx{display:block}
|
|
689
|
+
.rateov.rated .rate-card>p{display:none}
|
|
690
|
+
.rate-cmt{display:none;flex-direction:column;gap:8px;margin-top:14px;width:min(340px,72vw);text-align:left}
|
|
691
|
+
.rateov.rated .rate-cmt{display:flex}
|
|
692
|
+
.rateov.done .rate-cmt{display:none}
|
|
693
|
+
.rate-cmt .rc-t{margin:0;font-size:13.5px;font-weight:700}
|
|
694
|
+
.rate-cmt .rc-t span{font-weight:500;color:#8a8478}
|
|
695
|
+
.rate-cmt textarea{border:1.5px solid #ddd6c8;border-radius:12px;padding:9px 11px;font:inherit;font-size:13.5px;line-height:1.45;resize:none;background:#fff;color:#1a1a1a}
|
|
696
|
+
.rate-cmt textarea:focus{outline:none;border-color:#b7ad99}
|
|
697
|
+
.rc-btns{display:flex;gap:8px;justify-content:flex-end}
|
|
698
|
+
.rc-btns button{border:0;border-radius:999px;padding:8px 16px;font-size:13px;font-weight:700;cursor:pointer}
|
|
699
|
+
.rc-skip{background:none;color:#8a8478}
|
|
700
|
+
.rc-go{background:#15130f;color:#fff}
|
|
701
|
+
/* Carte centrée (mode barre) : questions, coordonnées et créneaux restent SUR le document — sous le
|
|
702
|
+
bandeau de Léa (z-index), le regard ne repart pas vers le panneau. */
|
|
703
|
+
.qov{display:none;position:absolute;inset:0;z-index:12;background:rgba(10,9,7,.42);backdrop-filter:blur(3px);align-items:center;justify-content:center;padding-bottom:140px}
|
|
704
|
+
.qov.on{display:flex}
|
|
705
|
+
.qov-card{background:#faf8f4;color:#1a1a1a;border-radius:20px;padding:22px 24px;width:min(430px,86vw);box-shadow:0 24px 70px rgba(0,0,0,.5);display:flex;flex-direction:column;gap:9px;animation:dkovin .28s cubic-bezier(.22,1,.36,1)}
|
|
706
|
+
.qov-msg{font-size:14.5px;line-height:1.5;margin-bottom:3px}
|
|
707
|
+
.qov-opt{display:block;width:100%;text-align:left;border:1.5px solid #ddd6c8;background:#fff;border-radius:12px;padding:11px 14px;font:inherit;font-size:14px;font-weight:600;color:#1a1a1a;cursor:pointer;transition:border-color .12s,background .12s}
|
|
708
|
+
.qov-opt:hover{border-color:#b7ad99;background:#fbf9f4}
|
|
709
|
+
.qov-opt.other{color:#6b665e;font-weight:500}
|
|
710
|
+
.has-ic svg{width:15px;height:15px;vertical-align:-2.5px;margin-right:3px;opacity:.8}
|
|
711
|
+
.qov-card .botc-form{box-shadow:none;border-color:#ddd6c8;padding:0;border:0;background:none}
|
|
712
|
+
/* Page MERCI : le document se floute, l'avatar de l'assistant au centre, un merci — rien d'autre. */
|
|
713
|
+
.byeov{display:none;position:absolute;inset:0;z-index:60;background:rgba(14,12,9,.42);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);align-items:center;justify-content:center}
|
|
714
|
+
.byeov.on{display:flex}
|
|
715
|
+
body.light .byeov{background:rgba(238,235,229,.55)}
|
|
716
|
+
.bye-card{display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center;animation:dkovin .4s cubic-bezier(.22,1,.36,1)}
|
|
717
|
+
.bye-av{width:92px;height:92px;border-radius:50%;overflow:hidden;box-shadow:0 18px 60px rgba(0,0,0,.35);border:4px solid rgba(255,255,255,.85)}
|
|
718
|
+
.bye-av img{width:100%;height:100%;object-fit:cover}
|
|
719
|
+
.bye-card b{font-size:30px;letter-spacing:-.02em;color:#fff;margin-top:6px}
|
|
720
|
+
.bye-card p{margin:0;font-size:15px;color:rgba(255,255,255,.82);font-weight:600}
|
|
721
|
+
body.light .bye-card b{color:#15130f}
|
|
722
|
+
body.light .bye-card p{color:#5b564d}
|
|
723
|
+
body.botplayer .qov{display:none !important}
|
|
724
|
+
@media(max-width:820px){.rateov{display:none !important}}
|
|
725
|
+
body.onepage .op-arrow{display:flex}
|
|
726
|
+
.op-prev{left:16px}.op-next{right:16px}
|
|
727
|
+
/* Choix = liste VERTICALE d'options avec puce radio (scannable, cliquable au pouce) ; la sélection se
|
|
728
|
+
marque un instant avant de partir dans le fil. Les créneaux de RDV gardent une identité bleue (📅). */
|
|
729
|
+
.botc-choices{display:flex;flex-direction:column;gap:7px;padding:4px 0 2px 29px}
|
|
730
|
+
.botc-choices:empty{display:none}
|
|
731
|
+
.botc-opt{display:flex;align-items:center;gap:10px;width:100%;text-align:left;border:1.5px solid #ddd6c9;background:#fff;color:#1a1a1a;border-radius:12px;padding:10px 13px;font:inherit;font-size:13px;line-height:1.35;cursor:pointer;transition:border-color .12s,background .12s,transform .12s;animation:botmsg .26s cubic-bezier(.22,1,.36,1);box-shadow:0 2px 10px rgba(0,0,0,.06)}
|
|
732
|
+
.botc-opt::after{content:"›";margin-left:auto;color:#b9b0a0;font-size:17px;line-height:1;flex:none;transition:transform .12s,color .12s}
|
|
733
|
+
.botc-opt:hover{border-color:var(--bacc,#15130f);background:#faf7f1;transform:translateX(2px)}
|
|
734
|
+
.botc-opt:hover::after{color:var(--bacc,#15130f);transform:translateX(2px)}
|
|
735
|
+
.botc-opt:active{transform:scale(.985)}
|
|
736
|
+
.botc-opt.book::after{color:#7db4ee}
|
|
737
|
+
.botc-opt .r{flex:none;width:16px;height:16px;border-radius:50%;border:2px solid #c9c1b2;position:relative;transition:border-color .12s}
|
|
738
|
+
.botc-opt:hover .r{border-color:var(--bacc,#15130f)}
|
|
739
|
+
.botc-opt.on{border-color:var(--bacc,#15130f);background:#f6f1e8}
|
|
740
|
+
.botc-opt.on .r{border-color:var(--bacc,#15130f)}
|
|
741
|
+
.botc-opt.on .r::after{content:"";position:absolute;inset:2.5px;border-radius:50%;background:var(--bacc,#15130f)}
|
|
742
|
+
.botc-opt:disabled{opacity:.5;cursor:default;transform:none}
|
|
743
|
+
.botc-opt.on:disabled{opacity:1}
|
|
744
|
+
.botc-opt.book{border-color:#a9cdf3;background:#f4f9ff}
|
|
745
|
+
.botc-opt.book:hover{border-color:#0a84ff}
|
|
746
|
+
.botc-opt.book:hover .r,.botc-opt.book.on .r{border-color:#0a84ff}
|
|
747
|
+
.botc-opt.book.on{border-color:#0a84ff;background:#e9f3ff}
|
|
748
|
+
.botc-opt.book.on .r::after{background:#0a84ff}
|
|
749
|
+
/* Mise en forme dans les bulles : gras, italique, souligné, puces. */
|
|
750
|
+
.botc-msg b{font-weight:700}
|
|
751
|
+
.botc-msg .li{display:block;position:relative;padding-left:15px;margin:2px 0}
|
|
752
|
+
.botc-msg .li::before{content:"•";position:absolute;left:2px;color:var(--bacc,#15130f);font-weight:700}
|
|
753
|
+
/* Badge non-lu sur la bulle flottante (chat réduit). */
|
|
754
|
+
.botc-badge{position:absolute;top:-4px;right:-4px;min-width:19px;height:19px;border-radius:10px;background:#e5484d;color:#fff;font-size:11px;font-weight:700;display:none;align-items:center;justify-content:center;padding:0 5px;border:2px solid #fff;line-height:1}
|
|
755
|
+
.botc-in{display:flex;gap:8px;padding:10px 12px;border-top:1px solid #0001;background:#fff}
|
|
756
|
+
/* Formulaire conversationnel : quand des choix sont proposés, la saisie s'efface — l'option « Autre »
|
|
757
|
+
(pointillés) la révèle. L'écran ne montre que la question et les réponses possibles. */
|
|
758
|
+
.botc-in.hid{display:none}
|
|
759
|
+
.botc-opt.other{border-style:dashed;color:#6b6457;font-weight:500;box-shadow:none}
|
|
760
|
+
.botc-opt.other .r{border-style:dashed}
|
|
761
|
+
/* Formulaire de coordonnées NATIF : une saisie, zéro aller-retour IA, réponse immédiate. */
|
|
762
|
+
.botc-form{display:flex;flex-direction:column;gap:8px;align-self:stretch;background:#fff;border:1.5px solid #ddd6c9;border-radius:14px;padding:12px;box-shadow:0 2px 10px rgba(0,0,0,.06);animation:botmsg .26s cubic-bezier(.22,1,.36,1)}
|
|
763
|
+
.botc-form input{border:1px solid #e0dcd4;border-radius:10px;padding:10px 13px;font:inherit;font-size:16px;background:#fff;color:#1a1a1a;width:100%}
|
|
764
|
+
.botc-form input.err{border-color:#e5484d}
|
|
765
|
+
.botc-form button{border:0;border-radius:10px;padding:11px;background:var(--bacc,#15130f);color:#fff;font:inherit;font-size:13.5px;font-weight:700;cursor:pointer}
|
|
766
|
+
.botc-form button:disabled{opacity:.6}
|
|
767
|
+
.botc-form .cf-priv{font-size:11px;line-height:1.45;color:#8a857d;margin:2px 2px 0}
|
|
768
|
+
/* Attente longue : au-delà de ~2 s, les points de frappe se doublent d'un mot doux. */
|
|
769
|
+
.botc-typing .ty-lbl{font-size:12px;color:#8a857c;margin-left:7px;align-self:center;white-space:nowrap}
|
|
770
|
+
.botc-in input{flex:1;min-width:0;border:1px solid #e0dcd4;border-radius:999px;padding:9px 15px;font:inherit;font-size:16px;background:#fff;color:#1a1a1a}
|
|
771
|
+
.botc-in button{flex:none;width:38px;height:38px;border:0;border-radius:50%;background:var(--bacc,#0a84ff);color:#fff;font-size:16px;cursor:pointer;line-height:0}
|
|
772
|
+
.botc-in button:disabled{opacity:.5;cursor:default}
|
|
773
|
+
/* FAB : anneau blanc qui détache l'avatar du fond + halo accent pulsant 2 fois à l'apparition (pattern
|
|
774
|
+
Intercom : attire l'œil sans harceler — le display none→flex relance l'animation à chaque réapparition). */
|
|
775
|
+
.botc-fab{display:none;position:fixed;right:16px;bottom:16px;width:60px;height:60px;border-radius:50%;border:3px solid #fff;background:var(--bacc,#15130f);color:#fff;font-size:23px;cursor:pointer;box-shadow:0 8px 26px rgba(0,0,0,.4);z-index:30;padding:0;align-items:center;justify-content:center;animation:fabhalo 1.7s ease-out 2}
|
|
776
|
+
@keyframes fabhalo{0%{box-shadow:0 8px 26px rgba(0,0,0,.4),0 0 0 0 color-mix(in srgb,var(--bacc,#15130f) 55%,transparent)}100%{box-shadow:0 8px 26px rgba(0,0,0,.4),0 0 0 20px transparent}}
|
|
777
|
+
/* Poignée de drag (mobile) : la sheet se manipule au doigt comme une app native. */
|
|
778
|
+
.botc-grab{display:none;flex:none;align-items:center;justify-content:center;height:22px;padding-top:8px;cursor:grab;touch-action:none}
|
|
779
|
+
.botc-grab i{width:42px;height:5px;border-radius:999px;background:#ded7ca}
|
|
780
|
+
/* ── Mobile : bottom sheet à 3 états — réduite (bulle) / COMPAGNON 48dvh (défaut : la page reste visible
|
|
781
|
+
au-dessus, re-fit automatique) / PLEINE 88dvh (lecture longue, clavier ; classe body.botsheet-c). ── */
|
|
782
|
+
@media(max-width:820px){
|
|
783
|
+
.botc{position:fixed;left:0;right:0;bottom:0;top:auto;width:auto;max-width:none;height:48vh;height:48dvh;border-left:0;border-radius:18px 18px 0 0;box-shadow:0 -12px 44px rgba(0,0,0,.4);z-index:40;transition:height .28s cubic-bezier(.22,1,.36,1);overscroll-behavior:contain}
|
|
784
|
+
body.botsheet-c .botc{height:88vh;height:88dvh}
|
|
785
|
+
.botc-grab{display:flex}
|
|
786
|
+
.botc-h{padding:4px 14px 10px;touch-action:none}
|
|
787
|
+
.botc-min{width:38px;height:38px;font-size:20px}
|
|
788
|
+
.botc-voice{width:38px;height:38px}
|
|
789
|
+
.botc-voice svg{width:19px;height:19px}
|
|
790
|
+
.botc-choices{padding-left:0}
|
|
791
|
+
.botc-opt{min-height:46px}
|
|
792
|
+
.botc-in{padding:10px 12px calc(10px + env(safe-area-inset-bottom))}
|
|
793
|
+
.botc-fab{bottom:calc(16px + env(safe-area-inset-bottom))}
|
|
794
|
+
/* État plein : léger scrim sur le document (focus conversation) — il réapparaît dès qu'on redescend. */
|
|
795
|
+
body.botsheet-c .scroll::after{content:"";position:absolute;inset:0;background:rgba(20,17,12,.28);z-index:5;pointer-events:none}
|
|
796
|
+
/* Navigation au doigt (tap bords de page + swipe horizontal) → les flèches rondes disparaissent,
|
|
797
|
+
SAUF en lecture solo page-à-page (botread) où elles reviennent en version discrète. */
|
|
798
|
+
.op-arrow{display:none !important}
|
|
799
|
+
body.onepage.botread .op-arrow{display:flex !important;width:36px;height:36px;font-size:19px;background:rgba(20,17,12,.42)}
|
|
800
|
+
.op-prev{left:8px}.op-next{right:8px}
|
|
801
|
+
/* Une question prend le pas sur la présentation → le document se met en retrait (flou + assombri). */
|
|
802
|
+
.scroll{transition:filter .32s}
|
|
803
|
+
body.botq .scroll{filter:blur(7px) brightness(.55)}
|
|
804
|
+
}
|
|
805
|
+
/* ── Écran d'accueil « 3 portes » (mobile) : le prospect choisit COMMENT découvrir le document ── */
|
|
806
|
+
.botw{display:none;position:fixed;inset:0;z-index:50;background:rgba(15,13,10,.55);backdrop-filter:blur(3px);align-items:flex-end}
|
|
807
|
+
.botw.on{display:flex}
|
|
808
|
+
.botw-card{width:100%;background:#faf8f4;color:#1a1a1a;border-radius:22px 22px 0 0;padding:20px 16px calc(16px + env(safe-area-inset-bottom));box-shadow:0 -18px 60px rgba(0,0,0,.5);animation:botwup .32s cubic-bezier(.22,1,.36,1)}
|
|
809
|
+
@keyframes botwup{from{transform:translateY(46px);opacity:0}to{transform:none;opacity:1}}
|
|
810
|
+
.botw-head{display:flex;align-items:center;gap:12px}
|
|
811
|
+
.botw-av{width:44px;height:44px;border-radius:50%;background:var(--bacc,#15130f);color:#fff;font-weight:800;font-size:17px;display:flex;align-items:center;justify-content:center;overflow:hidden;flex:none}
|
|
812
|
+
.botw-av img{width:100%;height:100%;object-fit:cover}
|
|
813
|
+
.botw-head b{font-size:15px;display:block;line-height:1.25}
|
|
814
|
+
.botw-head span{font-size:12px;color:#8a857c}
|
|
815
|
+
.botw-q{font-size:14px;margin:12px 0 13px;color:#3d382f}
|
|
816
|
+
.botw-door{display:flex;align-items:center;gap:13px;width:100%;text-align:left;border:1.5px solid #ddd6c9;background:#fff;border-radius:14px;padding:13px 14px;font:inherit;font-size:14px;font-weight:600;color:#1a1a1a;cursor:pointer;margin-bottom:9px}
|
|
817
|
+
.botw-door:active{border-color:var(--bacc,#15130f);background:#f6f1e8}
|
|
818
|
+
.botw-door i{font-style:normal;font-size:19px;flex:none;width:26px;text-align:center}
|
|
819
|
+
.botw-door small{display:block;font-weight:400;font-size:12px;color:#8a857c;margin-top:2px}
|
|
820
|
+
.botw-pitch{margin:10px 0 0;font-size:13px;line-height:1.5;color:#5a554d}
|
|
821
|
+
.botw-door{position:relative}
|
|
822
|
+
.botw-lang{display:flex;gap:6px;justify-content:flex-end;margin:2px 0 10px}
|
|
823
|
+
.botw-lang button{border:1px solid #ddd6c9;background:#fff;color:#8a857c;border-radius:999px;padding:4px 11px;font:inherit;font-size:11.5px;font-weight:700;cursor:pointer;transition:background .12s,color .12s}
|
|
824
|
+
.botw-lang button.on{background:#15130f;color:#fff;border-color:#15130f}
|
|
825
|
+
.botw-tag{position:absolute;top:-8px;right:12px;background:var(--bacc,#15130f);color:#fff;font-style:normal;font-size:9.5px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;padding:2.5px 9px;border-radius:999px;box-shadow:0 2px 8px rgba(0,0,0,.18)}
|
|
826
|
+
.botw-card.botw-s2{display:none}
|
|
827
|
+
.botw.step2 .botw-card:not(.botw-s2){display:none}
|
|
828
|
+
.botw.step2 .botw-card.botw-s2{display:block}
|
|
829
|
+
.botw-back{display:block;margin:4px auto 0;border:0;background:none;color:#8a857c;font:inherit;font-size:13px;cursor:pointer;padding:6px 10px}
|
|
830
|
+
.botw-back:hover{color:#3d382f}
|
|
831
|
+
.botc-pause{display:none;margin-left:auto;border:0;background:#efece7;color:#5a554d;width:28px;height:28px;border-radius:8px;cursor:pointer;line-height:0;align-items:center;justify-content:center}
|
|
832
|
+
body.deskpresent .botc-pause{display:inline-flex}
|
|
833
|
+
.botc-voice~.botc-pause{margin-left:6px}
|
|
834
|
+
.botc-pause+.botc-min{margin-left:6px}
|
|
835
|
+
.botc-pause svg{width:15px;height:15px}
|
|
836
|
+
.botc-pause svg+svg{display:none}
|
|
837
|
+
body.deskpaused .botc-pause svg{display:none}
|
|
838
|
+
body.deskpaused .botc-pause svg+svg{display:block}
|
|
839
|
+
body.deskpaused .botc-pause{background:var(--bacc,#15130f);color:#fff}
|
|
840
|
+
.botc-gearbtn{margin-left:auto;border:0;background:#efece7;color:#5a554d;width:28px;height:28px;border-radius:8px;cursor:pointer;line-height:0;display:inline-flex;align-items:center;justify-content:center}
|
|
841
|
+
.botc-voice~.botc-gearbtn{margin-left:6px}
|
|
842
|
+
.botc-gearbtn svg{width:15px;height:15px}
|
|
843
|
+
.botc-gearbtn:hover{background:#e2e0da}
|
|
844
|
+
.botc-gearbtn+.botc-min{margin-left:6px}
|
|
845
|
+
@media(max-width:820px){.botc-gearbtn{display:none}}
|
|
846
|
+
.botc-gear{position:absolute;right:-3px;bottom:-3px;width:22px;height:22px;border-radius:50%;background:#15130f;color:#fff;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 2px #faf8f4}
|
|
847
|
+
.botc-gear svg{width:12px;height:12px}
|
|
848
|
+
@media(max-width:820px){.botc-gear{display:none}}
|
|
849
|
+
.fab-gear{display:none}
|
|
850
|
+
@media(min-width:821px){
|
|
851
|
+
.botc-fab>img,.botc-fab>svg{display:none}
|
|
852
|
+
.botc-fab .botc-gear{display:none}
|
|
853
|
+
/* Cluster COMPACT en verre (assorti au header) : 2 pastilles discrètes 42px — 💬 puis ⚙. */
|
|
854
|
+
.botc-fab{width:42px;height:42px;border:0;background:rgba(24,22,18,.62);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);box-shadow:0 4px 16px rgba(0,0,0,.28);animation:none}
|
|
855
|
+
.botc-fab:hover{background:rgba(24,22,18,.85)}
|
|
856
|
+
body.light .botc-fab{background:rgba(255,255,255,.78);color:#15130f;box-shadow:0 4px 16px rgba(0,0,0,.16)}
|
|
857
|
+
body.light .botc-fab:hover{background:#fff}
|
|
858
|
+
.fab-gear{display:flex;align-items:center;justify-content:center}
|
|
859
|
+
.fab-gear svg{width:17px;height:17px}
|
|
860
|
+
}
|
|
861
|
+
.fabmenu{display:none;position:fixed;right:18px;bottom:170px;z-index:41;width:322px;background:#faf8f4;color:#1a1a1a;border-radius:16px;box-shadow:0 18px 60px rgba(0,0,0,.45);padding:12px;animation:dkovin .22s cubic-bezier(.22,1,.36,1)}
|
|
862
|
+
.fabmenu.on{display:block}
|
|
863
|
+
/* Menu à DEUX NIVEAUX façon réglages YouTube : niveau 0 = rangées avec la valeur courante, un tap
|
|
864
|
+
ouvre le panneau de la section (fm-p) avec un « retour ». 3 rangées lisibles au lieu de 13 boutons. */
|
|
865
|
+
.fm-row{display:flex;align-items:center;gap:8px;width:100%;border:0;background:none;border-radius:10px;padding:11px 10px;font:inherit;font-size:13.5px;font-weight:600;color:#1a1a1a;cursor:pointer;text-align:left}
|
|
866
|
+
.fm-row:hover{background:#f1ede4}
|
|
867
|
+
.fm-row .fm-rl{flex:1}
|
|
868
|
+
.fm-row em{font-style:normal;font-size:12px;color:#8a857c;font-weight:600;white-space:nowrap}
|
|
869
|
+
.fm-row svg{width:12px;height:12px;flex:none;color:#b3ac9f}
|
|
870
|
+
.fm-p{display:none}
|
|
871
|
+
.fm-back{display:flex;align-items:center;gap:9px;width:100%;border:0;background:none;border-radius:10px;padding:8px 10px 10px;font:inherit;font-size:13px;font-weight:700;color:#1a1a1a;cursor:pointer;text-align:left}
|
|
872
|
+
.fm-back:hover{background:#f1ede4}
|
|
873
|
+
.fm-back svg{width:13px;height:13px;color:#8a857c}
|
|
874
|
+
.fabmenu.p-pDisp #fmL0,.fabmenu.p-pLang #fmL0,.fabmenu.p-pLook #fmL0{display:none}
|
|
875
|
+
.fabmenu.p-pDisp #pDisp,.fabmenu.p-pLang #pLang,.fabmenu.p-pLook #pLook{display:block}
|
|
876
|
+
.fm-sec{font-size:10.5px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#8a857c;margin:11px 2px 6px}
|
|
877
|
+
.fm-seg{display:flex;gap:5px;flex-wrap:wrap}
|
|
878
|
+
.fm-seg button{flex:1 1 auto;min-width:0;white-space:nowrap;border:1.5px solid #ddd6c9;background:#fff;border-radius:9px;padding:7px 9px;font:inherit;font-size:12px;font-weight:600;cursor:pointer;color:#1a1a1a}
|
|
879
|
+
.fm-seg button.on{border-color:var(--bacc,#15130f);background:var(--bacc,#15130f);color:#fff}
|
|
880
|
+
@media(max-width:820px){.fabmenu{display:none !important}}
|
|
881
|
+
/* Desktop : DEUX boutons empilés — 💬 avatar (parler à l'assistant) au-dessus du ⚙ (réglages purs).
|
|
882
|
+
L'action de conversation sort du menu réglages : Léa retrouve un visage cliquable. */
|
|
883
|
+
.botc-fab2{display:none;position:fixed;right:16px;bottom:66px;width:42px;height:42px;border-radius:50%;border:0;background:rgba(24,22,18,.62);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.28);z-index:30;padding:0;align-items:center;justify-content:center;overflow:visible;transition:background .12s}
|
|
884
|
+
.botc-fab2:hover{background:rgba(24,22,18,.85)}
|
|
885
|
+
body.light .botc-fab2{background:rgba(255,255,255,.78);color:#15130f;box-shadow:0 4px 16px rgba(0,0,0,.16)}
|
|
886
|
+
body.light .botc-fab2:hover{background:#fff}
|
|
887
|
+
.botc-fab2 svg{width:17px;height:17px}
|
|
888
|
+
.botc-fab3{bottom:116px}
|
|
889
|
+
.botc-fab4{bottom:116px}
|
|
890
|
+
@media(max-width:820px){.botc-fab2{display:none !important}}
|
|
891
|
+
@media(min-width:821px){.botc-peek{bottom:172px}}
|
|
892
|
+
body.light{--bg:#e8e5df;--bar:#f7f5f1;color:#1a1a1a}
|
|
893
|
+
body.light .bar{border-bottom-color:#00000014}
|
|
894
|
+
/* Thème clair : les contrôles de la barre étaient codés en BLANC → invisibles sur fond clair. */
|
|
895
|
+
body.light .pg,body.light .zoom span{color:#6b665e}
|
|
896
|
+
body.light .zoom button,body.light .dl,body.light .ic{color:#15130f;border-color:#00000024}
|
|
897
|
+
body.light .zoom button:hover,body.light .dl:hover,body.light .ic:hover{background:#00000010}
|
|
898
|
+
body.light .dl.primary{background:#15130f;color:#fff;border-color:#15130f}
|
|
899
|
+
body.light .dl.primary:hover{filter:none;background:#2a261f}
|
|
900
|
+
body.light .dkov{background:rgba(233,230,224,.62)}
|
|
901
|
+
body.light .dkov-big{background:#15130f;color:#fff}
|
|
902
|
+
body.light .dkov-opts button{background:rgba(21,19,15,.9);color:#fff}
|
|
903
|
+
body.light .dkov-opts button:hover{background:#15130f}
|
|
904
|
+
body.light .dkov-opts button.primary{background:#fff;color:#15130f}
|
|
905
|
+
body.light .dkov-opts button.primary:hover{background:#f1ede4}
|
|
906
|
+
body.light .dkov-opts button.ghost{background:none;border-color:rgba(0,0,0,.32);color:#3c3833}
|
|
907
|
+
body.light .dkov-opts button.ghost:hover{background:rgba(0,0,0,.08)}
|
|
908
|
+
.botw-x{position:absolute;top:calc(14px + env(safe-area-inset-top));right:14px;width:40px;height:40px;border-radius:50%;border:0;background:rgba(255,255,255,.16);color:#fff;font-size:17px;cursor:pointer}
|
|
909
|
+
/* ── Desktop : les mêmes « 3 portes » mais en CARTE CENTRÉE (pas un bottom sheet) + états hover souris. ── */
|
|
910
|
+
@media(min-width:821px){
|
|
911
|
+
.botw{align-items:center;justify-content:center}
|
|
912
|
+
.botw-card{width:440px;max-width:92vw;border-radius:20px;padding:26px 24px;box-shadow:0 30px 80px rgba(0,0,0,.45)}
|
|
913
|
+
.botw-x{top:16px;right:16px}
|
|
914
|
+
.botw-q{font-size:15px;margin:14px 0 16px}
|
|
915
|
+
.botw-door{padding:15px 16px;font-size:14.5px;transition:border-color .12s,background .12s}
|
|
916
|
+
.botw-door:hover{border-color:var(--bacc,#15130f);background:#f6f1e8}
|
|
917
|
+
}
|
|
918
|
+
/* ── Mode PLAYER (mobile) : le document plein écran, sous-titres courts + contrôles façon stories ── */
|
|
919
|
+
.botp{display:none;position:fixed;left:0;right:0;bottom:0;z-index:36;padding:0 12px calc(12px + env(safe-area-inset-bottom));flex-direction:column;gap:9px;pointer-events:none}
|
|
920
|
+
body.botplayer .botp{display:flex}
|
|
921
|
+
body.botplayer .botc,body.botplayer .botc-fab,body.botplayer .botc-peek{display:none !important}
|
|
922
|
+
@media (orientation: landscape) and (max-height: 520px){
|
|
923
|
+
body.botplayer .bar{display:none}
|
|
924
|
+
body.botplayer .botp{padding:0 0 6px}
|
|
925
|
+
body.botplayer .botp-cap{position:fixed;left:0;right:0;bottom:0;margin:0;padding:44px 26px calc(16px + env(safe-area-inset-bottom));background:linear-gradient(transparent,rgba(10,9,7,.72) 55%);backdrop-filter:none;-webkit-backdrop-filter:none;color:#fff;border-radius:0;box-shadow:none}
|
|
926
|
+
body.botplayer .pcap-k{font-size:30px;min-height:0}
|
|
927
|
+
body.botplayer .botp-cap .ks-focus .kw.cur{background:#fff;color:#15130f}
|
|
928
|
+
body.botplayer .botp-ctl{position:fixed;right:12px;bottom:calc(10px + env(safe-area-inset-bottom));gap:8px;transition:opacity .3s}
|
|
929
|
+
body.botplayer .botp-ctl button{width:40px;height:40px}
|
|
930
|
+
body.botplayer .botp-ctl button.pp{width:44px;height:44px}
|
|
931
|
+
body.botplayer.ctlhide .botp-ctl{opacity:0;pointer-events:none}
|
|
932
|
+
body.botplayer .botp-prog{position:fixed;top:0;left:0;right:0;margin:0}
|
|
933
|
+
}
|
|
934
|
+
.rot-hint{display:none;position:fixed;left:50%;transform:translateX(-50%);bottom:calc(210px + env(safe-area-inset-bottom));z-index:44;background:rgba(16,14,11,.82);color:#fff;border-radius:16px;padding:12px 18px;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);box-shadow:0 12px 40px rgba(0,0,0,.35);align-items:center;gap:12px}
|
|
935
|
+
.rot-hint.on{display:flex;animation:botmsg .3s cubic-bezier(.22,1,.36,1)}
|
|
936
|
+
.rh-ph{display:flex;color:#fff;animation:rotph 2.4s ease-in-out infinite}
|
|
937
|
+
@keyframes rotph{0%,28%{transform:rotate(0)}55%,78%{transform:rotate(90deg)}100%{transform:rotate(0)}}
|
|
938
|
+
.rh-t{display:flex;flex-direction:column;line-height:1.25}
|
|
939
|
+
.rh-t b{font-size:13.5px;font-weight:800}
|
|
940
|
+
.rh-t span{font-size:11.5px;color:#c9c3b8;font-weight:600}
|
|
941
|
+
/* Barre TEMPO : une seule barre qui se remplit sur la durée de l'étape courante (le rythme), sans révéler
|
|
942
|
+
« il reste 14 slides » (anxiogène). Se fige en pause (body.botpaused), se masque pendant une question. */
|
|
943
|
+
.botp-prog{position:fixed;top:calc(56px + env(safe-area-inset-top));left:10px;right:10px;height:3px;border-radius:99px;background:rgba(255,255,255,.24);overflow:hidden;z-index:37;transition:opacity .3s}
|
|
944
|
+
.botp-prog i{display:block;height:100%;width:0;border-radius:99px;background:#fff}
|
|
945
|
+
@keyframes botfill{from{width:0}to{width:100%}}
|
|
946
|
+
body.botpaused .botp-prog i{animation-play-state:paused}
|
|
947
|
+
body.botq .botp-prog{opacity:0}
|
|
948
|
+
.botp-cap{pointer-events:auto;margin:0;background:linear-gradient(180deg,rgba(30,27,22,.88),rgba(14,12,9,.94));backdrop-filter:blur(16px) saturate(1.15);-webkit-backdrop-filter:blur(16px) saturate(1.15);color:#fff;padding:16px 18px 14px;font-size:16.5px;font-weight:600;line-height:1.4;text-align:center;display:flex;flex-direction:column;gap:7px;border-radius:24px;box-shadow:0 14px 44px rgba(0,0,0,.38),inset 0 1px 0 rgba(255,255,255,.09);animation:botmsg .3s cubic-bezier(.22,1,.36,1)} /* CARTE flottante en verre façon Captions — les deux thèmes */
|
|
949
|
+
.pcap-k{font-size:38px;font-weight:800;line-height:1.16;letter-spacing:-.02em;min-height:96px;display:flex;flex-wrap:wrap;align-content:center;justify-content:center;gap:0 9px} /* TRÈS PEU de mots, ÉNORMES */
|
|
950
|
+
.botp-cap .ks-focus .kw.cur{background:#fff;color:#15130f}
|
|
951
|
+
.botp-cap:empty{display:none}
|
|
952
|
+
.botp-chips{pointer-events:auto;display:flex;flex-direction:column;gap:7px}
|
|
953
|
+
.botp-chips:empty{display:none}
|
|
954
|
+
.botp-ctl{pointer-events:auto;display:flex;align-items:center;justify-content:center;gap:11px}
|
|
955
|
+
.botp-ctl button{width:46px;height:46px;border-radius:50%;border:0;background:rgba(20,17,12,.72);color:#fff;font-size:18px;cursor:pointer;line-height:0;backdrop-filter:blur(4px)}
|
|
956
|
+
.botp-ctl button.pp{width:56px;height:56px;background:#fff;color:#15130f;font-size:20px}
|
|
957
|
+
.botp-ctl button.spd{width:auto;min-width:46px;padding:0 11px;border-radius:999px;font-size:13px;font-weight:800;letter-spacing:.02em}
|
|
958
|
+
.botp-ctl button:disabled{opacity:.35}
|
|
959
|
+
#botpFs svg{width:17px;height:17px}
|
|
960
|
+
.botp-ctl #botpChat{padding:0;overflow:hidden;border:2px solid rgba(255,255,255,.35)}
|
|
961
|
+
.botp-ctl #botpChat img{width:100%;height:100%;object-fit:cover;border-radius:50%}
|
|
962
|
+
/* PAUSE visible : gros bouton lecture au centre (pattern lecteur vidéo). Pendant une question, les
|
|
963
|
+
contrôles s'effacent — il ne reste que la question et les réponses. */
|
|
964
|
+
.botp-big{display:none;position:fixed;top:40%;left:50%;transform:translate(-50%,-50%);width:78px;height:78px;border-radius:50%;border:0;background:rgba(255,255,255,.96);color:#15130f;box-shadow:0 16px 48px rgba(0,0,0,.45);cursor:pointer;z-index:37;align-items:center;justify-content:center;pointer-events:auto;animation:bigin .22s cubic-bezier(.22,1,.36,1)}
|
|
965
|
+
/* keyframe DÉDIÉE : botmsg écrasait le translate(-50%,-50%) pendant l'animation → le ▶ naissait décentré */
|
|
966
|
+
@keyframes bigin{from{opacity:0;transform:translate(-50%,-50%) scale(.82)}to{opacity:1;transform:translate(-50%,-50%) scale(1)}}
|
|
967
|
+
.botp-big svg{width:30px;height:30px;display:block;margin:auto}
|
|
968
|
+
body.botplayer.botpaused .botp-big{display:flex}
|
|
969
|
+
body.botq .botp-big{display:none !important}
|
|
970
|
+
body.botq .botp-ctl{display:none}
|
|
971
|
+
/* Question = plein FOCUS : le bloc question+réponses monte au CENTRE de l'écran sur le doc flouté. */
|
|
972
|
+
body.botq .botp{top:0;justify-content:center}
|
|
973
|
+
/* Le document règne : page calée en HAUT (réserve basse pour sous-titre/contrôles, cf. targetWidth). */
|
|
974
|
+
body.botplayer.onepage #pages{justify-content:center;align-items:center} /* centré dans l'espace AU-DESSUS du texte (padding-bottom dynamique, cf. build) */
|
|
975
|
+
/* Menu « reprendre la main » (⋯) : les sorties du GOAL, accessibles à TOUT moment de la présentation. */
|
|
976
|
+
.botp-menu{display:none;position:fixed;right:12px;bottom:calc(88px + env(safe-area-inset-bottom));z-index:39;background:#fff;color:#1a1a1a;border-radius:14px;box-shadow:0 18px 54px rgba(0,0,0,.45);padding:6px;min-width:250px;pointer-events:auto;animation:botmsg .2s cubic-bezier(.22,1,.36,1)}
|
|
977
|
+
.botp-menu.on{display:block}
|
|
978
|
+
.botp-menu button{display:flex;align-items:center;gap:11px;width:100%;text-align:left;border:0;background:transparent;font:inherit;font-size:13.5px;font-weight:600;color:#1a1a1a;padding:12px;border-radius:9px;cursor:pointer}
|
|
979
|
+
.botp-menu .bm-set{border-top:1px solid #e8e2d6;border-radius:0 0 9px 9px;margin-top:4px;padding-top:13px;color:#6b665e}
|
|
980
|
+
.botp-set{display:none}
|
|
981
|
+
.botp-set.on{display:block}
|
|
982
|
+
.botp-set .fm-sec{margin:10px 4px 6px}
|
|
983
|
+
.botp-set .fm-seg{display:flex;gap:6px;flex-wrap:wrap}
|
|
984
|
+
.botp-set .fm-seg button{width:auto;flex:1 1 auto;min-width:0;padding:9px 10px;text-align:center;border-radius:10px}
|
|
985
|
+
.bs-back{display:flex;align-items:center;gap:9px;width:100%;border:0;background:none;font:inherit;font-size:13.5px;font-weight:800;color:#1a1a1a;padding:6px 4px 8px;cursor:pointer}
|
|
986
|
+
.bs-back svg{width:13px;height:13px;color:#8a857c}
|
|
987
|
+
.botp-menu b{width:20px;font-size:12.5px;font-weight:800;flex:none;text-align:center}
|
|
988
|
+
.botp-menu button:active{background:#f2efe9}
|
|
989
|
+
.botp-menu button svg{flex:none;color:#8a857c}
|
|
990
|
+
/* Les choix apparaissent EN CASCADE (le temps de lire) : délai posé en JS, invisibles avant leur tour. */
|
|
991
|
+
.botc-opt{animation-fill-mode:both}
|
|
992
|
+
/* Icônes SVG : centrées dans les boutons ; le bouton lecture/pause embarque les DEUX icônes, l'état
|
|
993
|
+
body.botpaused choisit laquelle afficher (pas de innerHTML côté JS). */
|
|
994
|
+
.botp-ctl button svg,.botc-in button svg,.botc-min svg,.botw-x svg,.op-arrow svg{display:block;margin:auto}
|
|
995
|
+
.botc-in button svg{width:16px;height:16px}
|
|
996
|
+
.botc-min svg{width:16px;height:16px}
|
|
997
|
+
.botp-ctl .pp svg{width:21px;height:21px}
|
|
998
|
+
.botp-ctl .pp svg+svg{display:none}
|
|
999
|
+
body.botpaused .botp-ctl .pp svg:first-child{display:none}
|
|
1000
|
+
body.botpaused .botp-ctl .pp svg+svg{display:block}
|
|
1001
|
+
.botw-door i svg{width:21px;height:21px}
|
|
1002
|
+
/* Pill « reprendre la présentation » dans le chat quand un player est en pause */
|
|
1003
|
+
.botc-back{display:none;align-items:center;justify-content:center;gap:8px;margin:8px 12px 0;padding:9px;border-radius:11px;border:0;background:var(--bacc,#15130f);color:#fff;font:inherit;font-size:12.5px;font-weight:700;cursor:pointer}
|
|
1004
|
+
.botc-back svg{width:13px;height:13px}
|
|
1005
|
+
.botc-back:hover{filter:brightness(1.25)}
|
|
1006
|
+
.botc-resume{display:none;margin:0 12px 8px;padding:11px;border-radius:12px;border:1.5px solid var(--bacc,#15130f);background:#fff;color:var(--bacc,#15130f);font:inherit;font-size:13px;font-weight:600;cursor:pointer}
|
|
1007
|
+
/* Vignette d'une page insérée dans le fil (le bot MONTRE ce dont il parle) — tap = voir la page en grand. */
|
|
1008
|
+
.botc-pgcard{align-self:flex-start;margin-left:29px;border:1px solid #d6cfc2;border-radius:12px;overflow:hidden;background:#fff;cursor:pointer;max-width:72%;box-shadow:0 6px 18px rgba(0,0,0,.13);animation:botmsg .26s cubic-bezier(.22,1,.36,1)}
|
|
1009
|
+
.botc-pgcard img{display:block;width:100%}
|
|
1010
|
+
.botc-pgcard span{display:block;font-size:11px;color:#8a857c;padding:6px 10px;font-weight:600}
|
|
1011
|
+
.botc-pgchip{display:inline-block;background:#efe9df;color:#6b6457;font-size:11px;font-weight:700;border-radius:6px;padding:2px 7px;margin-right:6px;cursor:pointer;vertical-align:1px}
|
|
1012
|
+
/* Teaser : un message arrivé chat réduit s'affiche en bulle 1-2 lignes au-dessus de la bulle flottante
|
|
1013
|
+
(tap = ouvrir la conversation). Auto-disparition — invitant, jamais bloquant. */
|
|
1014
|
+
.botc-peek{position:fixed;right:16px;bottom:calc(88px + env(safe-area-inset-bottom));z-index:39;max-width:min(76vw,340px);background:#fff;color:#1c1c1c;border:0;border-radius:16px;border-bottom-right-radius:5px;padding:11px 14px;box-shadow:0 12px 34px rgba(0,0,0,.35);font:inherit;font-size:13px;line-height:1.45;text-align:left;cursor:pointer;visibility:hidden;opacity:0;transform:translateY(8px);transition:opacity .28s,transform .28s,visibility .28s;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} /* la troncature fiable est faite en JS (~90 car. au mot) ; le clamp n'est qu'un filet */
|
|
1015
|
+
.botc-peek.on{visibility:visible;opacity:1;transform:none}
|
|
1016
|
+
`;
|
|
1017
|
+
// Icônes SVG fines (stroke currentColor, style feather) — remplacent les emoji des contrôles (moderne, cohérent).
|
|
1018
|
+
const ICO = (d) => `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
|
|
1019
|
+
const ICONS = {
|
|
1020
|
+
play: ICO('<path d="M7 4.5l13 7.5-13 7.5z" fill="currentColor" stroke="none"/>'),
|
|
1021
|
+
pause: ICO('<path d="M8.5 5v14M15.5 5v14" stroke-width="2.6"/>'),
|
|
1022
|
+
prev: ICO('<path d="M14.5 5 8 12l6.5 7"/>'),
|
|
1023
|
+
next: ICO('<path d="M9.5 5 16 12l-6.5 7"/>'),
|
|
1024
|
+
chat: ICO('<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z"/>'),
|
|
1025
|
+
close: ICO('<path d="M6 6l12 12M18 6 6 18"/>'),
|
|
1026
|
+
book: ICO('<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>'),
|
|
1027
|
+
send: ICO('<path d="M22 2 11 13M22 2l-7 20-4-9-9-4z"/>'),
|
|
1028
|
+
min: ICO('<path d="M6 9l6 6 6-6"/>'),
|
|
1029
|
+
more: ICO('<circle cx="5" cy="12" r="1.7" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.7" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.7" fill="currentColor" stroke="none"/>'),
|
|
1030
|
+
dl: ICO('<path d="M12 4v11M6 10l6 6 6-6M4 20h16"/>'),
|
|
1031
|
+
sound: ICO('<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/>'),
|
|
1032
|
+
restart: ICO('<path d="M3 12a9 9 0 1 0 2.8-6.5"/><path d="M3 4v5h5"/>'),
|
|
1033
|
+
gear: ICO('<circle cx="12" cy="12" r="3.1"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1-1.55 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.55-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34 1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.55 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87 1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/>'),
|
|
1034
|
+
cc: ICO('<rect x="3" y="6" width="18" height="13" rx="2.5"/><path d="M6.5 12h4M6.5 15.2h7M13 12h4.5"/>'),
|
|
1035
|
+
mute: ICO('<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M22 9l-6 6M16 9l6 6"/>'),
|
|
1036
|
+
phone: ICO('<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .4 2 .7 2.9a2 2 0 0 1-.5 2.1L8 10a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.5c.9.3 1.9.6 2.9.7a2 2 0 0 1 1.7 2z"/>'),
|
|
1037
|
+
cal: ICO('<rect x="4" y="5.5" width="16" height="15" rx="2.5"/><path d="M8 3v4M16 3v4M4 10.5h16"/>'),
|
|
1038
|
+
exit: ICO('<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>'),
|
|
1039
|
+
fs: ICO('<path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/>'),
|
|
1040
|
+
};
|
|
1041
|
+
function botMarkup(share, pitch) {
|
|
1042
|
+
const name = esc(share.bot_name || (PLAYER.branding.name ? `Assistant ${PLAYER.branding.name}` : "Assistant"));
|
|
1043
|
+
const sub = esc(share.bot_tagline || "Présentation guidée");
|
|
1044
|
+
const acc = esc(share.bot_accent || "#15130f");
|
|
1045
|
+
const avatar = share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : (share.bot_name ? esc(String(share.bot_name).trim().charAt(0).toUpperCase()) : "◆");
|
|
1046
|
+
// Voix (ElevenLabs) : les boutons 🔊 ne sont proposés que si une clé est configurée côté serveur.
|
|
1047
|
+
const voiceBtn = process.env.ELEVENLABS_API_KEY ? `<button class=botc-voice id=botcVoice title="Écouter la présentation" aria-pressed=false>${ICONS.mute}</button>` : "";
|
|
1048
|
+
const pVoiceBtn = process.env.ELEVENLABS_API_KEY ? `<button id=botpVoice title="Écouter">${ICONS.mute}</button>` : "";
|
|
1049
|
+
// Étape 2 du sélecteur de départ (uniquement si la voix est disponible) : le CONSENTEMENT audio se donne
|
|
1050
|
+
// ICI, clairement, avant de lancer la présentation — audio interactif (voix + chat) ou par écrit.
|
|
1051
|
+
const hasVClips = !!share.bot_vclips;
|
|
1052
|
+
const videoDoor = hasVClips ? `<button class=botw-door id=doorVideo><i>${ICONS.play}</i><span>En vidéo avec ${name}<small>${name} vous présente face caméra — le format le plus vivant</small></span><em class=botw-tag>Populaire</em></button>` : "";
|
|
1053
|
+
const vNote = !hasVClips && share.video_layout ? `<p class=botw-note>🎬 La présentation vidéo arrive bientôt sur ce document.</p>` : "";
|
|
1054
|
+
const s2 = process.env.ELEVENLABS_API_KEY ? `<div class="botw-card botw-s2"><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><p class=botw-q>Parfait ! Comment préférez-vous suivre la présentation ?</p>${videoDoor}<button class=botw-door id=doorVoice><i>${ICONS.sound}</i><span>${hasVClips ? "En audio" : `Avec la voix de ${name}`}<small>Audio interactif — écoutez la présentation et posez vos questions dans le chat à tout moment</small></span>${hasVClips ? "" : `<em class=botw-tag>Populaire</em>`}</button><button class=botw-door id=doorSilent><i>${ICONS.chat}</i><span>Par écrit, dans le chat<small>${name} écrit page après page, en silence — à votre rythme</small></span></button>${vNote}<button class=botw-back id=botwBack>← Revenir aux options</button></div>` : "";
|
|
1055
|
+
return `<div class="botc min" id=botc style="--bacc:${acc}"><div class=botc-grab id=botcGrab><i></i></div><div class=botc-h><span class=botc-av>${avatar}</span><div><b>${name}</b><span class=botc-sub>${sub}</span></div>${voiceBtn}<button class=botc-gearbtn id=botcGearBtn title="Réglages d'affichage">${ICONS.gear}</button><button class=botc-min id=botcMin title=Réduire>${ICONS.min}</button></div><button class=botc-back id=botcBack>${ICONS.prev}<span>Revenir à la présentation</span></button><div class=botc-msgs id=botcMsgs><div class=botc-choices id=botcChoices></div></div><button class=botc-resume id=botcResume>Reprendre la présentation</button><div class=botc-in><input id=botcText placeholder="Écrivez votre message…" autocomplete=off maxlength=1000><button id=botcSend title=Envoyer>${ICONS.send}</button></div></div><button class=botc-fab id=botcFab style="--bacc:${acc}" title="Assistant & réglages">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}<span class=botc-badge id=botcBadge></span><span class=botc-gear>${ICONS.gear}</span><span class=fab-gear>${ICONS.gear}</span></button><button class=botc-fab2 id=botcFab2 title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${ICONS.chat}<span class=botc-badge id=botcBadge2></span></button>${process.env.ELEVENLABS_API_KEY ? `<button class="botc-fab2 botc-fab3" id=botcVoice2 title="Couper la voix"></button>` : ""}<button class="botc-fab2 botc-fab4" id=botcPlay2 title="Relancer une visite">${ICONS.play}</button><button class=botc-peek id=botcPeek></button><div class=fabmenu id=fabMenu style="--bacc:${acc}"><div class=fm-l0 id=fmL0><button class=fm-row data-p=pDisp><span class=fm-rl>Affichage</span><em id=fmVDisp></em>${ICONS.next}</button><button class=fm-row data-p=pLang><span class=fm-rl>Langue</span><em id=fmVLang></em>${ICONS.next}</button><button class=fm-row data-p=pLook><span class=fm-rl>Apparence</span><em id=fmVLook></em>${ICONS.next}</button></div><div class=fm-p id=pDisp><button class=fm-back>${ICONS.prev}<span class=fm-rl>Affichage</span></button><div class=fm-seg id=fmDisp><button data-v=panel>Panneau</button><button data-v=bubble>Bulle</button><button data-v=cap>Barre</button><button data-v=audio>Audio seul</button></div></div><div class=fm-p id=pLang><button class=fm-back>${ICONS.prev}<span class=fm-rl>Langue</span></button><div class=fm-seg id=fmLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div></div><div class=fm-p id=pLook><button class=fm-back>${ICONS.prev}<span class=fm-rl>Apparence</span></button><div class=fm-sec>Thème</div><div class=fm-seg id=fmTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=fmStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div><div class=botw id=botw style="--bacc:${acc}"><button class=botw-x id=botwX aria-label=Fermer>${ICONS.close}</button><div class=botw-card><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><div class=botw-lang id=botwLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div>${pitch ? `<p class=botw-pitch>${esc(pitch)}</p>` : ""}<p class=botw-q>Comment souhaitez-vous découvrir ce document ?</p><button class=botw-door id=doorPresent><i>${ICONS.play}</i><span>Je me laisse guider<small>${name} vous présente le document, à votre rythme</small></span><em class=botw-tag>Recommandé</em></button><button class=botw-door id=doorRead><i>${ICONS.book}</i><span>Je le parcours seul<small>Lecture libre — l'assistant reste disponible</small></span></button><button class=botw-door id=doorChat><i>${ICONS.chat}</i><span>J'ai des questions<small>Échangez directement avec ${name}</small></span></button></div>${s2}</div><div class=botp id=botp style="--bacc:${acc}"><div class=botp-prog id=botpProg><i id=botpFill></i></div><div class=botp-cap id=botpCap></div><div class=botp-chips id=botpChips></div><div class=botp-ctl><button class=pp id=botpPP aria-label="Lecture / pause">${ICONS.pause}${ICONS.play}</button>${pVoiceBtn}<button id=botpFs title="Plein écran">${ICONS.fs}</button><button id=botpChat title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}</button><button id=botpMore title=Options>${ICONS.more}</button></div><button class=botp-big id=botpBig aria-label=Reprendre>${ICONS.play}</button><div class=rot-hint id=rotHint><i class=rh-ph><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="3" width="10" height="18" rx="2.5"/><path d="M11 18.2h2"/></svg></i><div class=rh-t><b>Plein écran</b><span>Tournez votre téléphone</span></div></div><div class=botp-menu id=botpMenu><button id=bmChat>${ICONS.chat}<span>Poser une question</span></button><button id=bmCall>${ICONS.cal}<span>Être rappelé / prendre RDV</span></button><button id=bmDl>${ICONS.dl}<span>Télécharger le document</span></button><button id=bmRestart>${ICONS.restart}<span>Recommencer la présentation</span></button><button id=bmRead>${ICONS.book}<span>Consulter tranquillement</span></button><button id=bmSet class=bm-set>${ICONS.gear}<span>Réglages</span></button></div><div class="botp-menu botp-set" id=botpSet><button class=bs-back id=bsBack>${ICONS.prev}<span>Réglages</span></button><div class=fm-sec>Vitesse de lecture</div><div class=fm-seg id=msSpd><button data-v=1>1×</button><button data-v=1.5>1,5×</button><button data-v=2>2×</button></div><div class=fm-sec>Thème</div><div class=fm-seg id=msTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=msStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div>`;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Pied de mentions d'une page servie. `tracked` = cette page mesure la lecture de la personne qui
|
|
1060
|
+
* la regarde — la mention de mesure n'apparaît QUE dans ce cas, et elle est affichée par défaut.
|
|
1061
|
+
* Les autres liens n'apparaissent que si l'hôte les a configurés : neutre par défaut, comme la marque.
|
|
1062
|
+
*/
|
|
1063
|
+
/**
|
|
1064
|
+
* Relaie un fichier vers le lecteur. À n'utiliser QUE via cette fonction — les trois chemins de
|
|
1065
|
+
* streaming (lien tracé, aperçu interne, page audience) faisaient la même chose à trois endroits,
|
|
1066
|
+
* et la même erreur.
|
|
1067
|
+
*
|
|
1068
|
+
* ⚠️ LE PIÈGE, signalé par le second hôte en implémentant sa propre route : `fetch()`
|
|
1069
|
+
* **décompresse le corps pour nous, et garde les en-têtes reçus**. Relayer fidèlement le
|
|
1070
|
+
* `Content-Length` de l'amont annonce donc la taille du COMPRESSÉ alors qu'on sert du décompressé
|
|
1071
|
+
* — le lecteur reçoit un PDF **tronqué**. Ce n'est plus « le chargement progressif ne marche pas »,
|
|
1072
|
+
* c'est « le document est corrompu », et sans erreur nulle part.
|
|
1073
|
+
*
|
|
1074
|
+
* On ne relaie donc JAMAIS la taille annoncée : on envoie celle des octets qu'on envoie vraiment.
|
|
1075
|
+
*
|
|
1076
|
+
* ⚠️ Et un 206 compressé est irrécupérable : les bornes portent sur les octets compressés, un
|
|
1077
|
+
* fragment gzip ne se décompresse pas seul. On refuse bruyamment plutôt que de servir du faux.
|
|
1078
|
+
*/
|
|
1079
|
+
async function relayerFichier(res, r, disposition) {
|
|
1080
|
+
if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
|
|
1081
|
+
if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
|
|
1082
|
+
|
|
1083
|
+
const compresse = !!r.headers.get("content-encoding");
|
|
1084
|
+
if (compresse && r.status === 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
|
|
1085
|
+
|
|
1086
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
1087
|
+
res.statusCode = r.status;
|
|
1088
|
+
res.setHeader("Content-Type", r.headers.get("content-type") || "application/pdf");
|
|
1089
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
1090
|
+
// Les bornes d'un `Content-Range` ne valent que si l'amont n'a pas compressé.
|
|
1091
|
+
const cr = !compresse && r.headers.get("content-range");
|
|
1092
|
+
if (cr) res.setHeader("Content-Range", cr);
|
|
1093
|
+
res.setHeader("Content-Length", String(buf.length)); // ce qu'on envoie, jamais ce qu'on a reçu
|
|
1094
|
+
if (disposition) res.setHeader("Content-Disposition", disposition);
|
|
1095
|
+
res.setHeader("Cache-Control", "private, max-age=600");
|
|
1096
|
+
res.end(buf);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function legalFooter({ tracked }) {
|
|
1100
|
+
const L = PLAYER.legal;
|
|
1101
|
+
const liens = [
|
|
1102
|
+
L.legalUrl ? `<a href="${esc(L.legalUrl)}" target=_blank rel=noreferrer>Mentions légales</a>` : "",
|
|
1103
|
+
L.privacyUrl ? `<a href="${esc(L.privacyUrl)}" target=_blank rel=noreferrer>Confidentialité</a>` : "",
|
|
1104
|
+
// Obligation AGPL : l'accès au source se propose à qui UTILISE le logiciel, pas seulement à qui le distribue.
|
|
1105
|
+
L.sourceUrl ? `<a href="${esc(L.sourceUrl)}" target=_blank rel=noreferrer>Code source</a>` : "",
|
|
1106
|
+
].filter(Boolean).join("<span class=lgl-sep>·</span>");
|
|
1107
|
+
const mesure = tracked ? `<span class=lgl-note>${esc(L.trackingNotice)}</span>` : "";
|
|
1108
|
+
if (!liens && !mesure) return "";
|
|
1109
|
+
return `<div class=lgl>${mesure}${liens ? `<span class=lgl-links>${liens}</span>` : ""}</div>`;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const LEGAL_CSS = `
|
|
1113
|
+
.lgl{position:fixed;left:0;right:0;bottom:0;z-index:5;display:flex;flex-wrap:wrap;gap:4px 10px;
|
|
1114
|
+
align-items:center;justify-content:center;padding:5px 12px;font-size:10.5px;line-height:1.35;
|
|
1115
|
+
color:rgba(255,255,255,.42);background:rgba(0,0,0,.28);backdrop-filter:blur(3px);pointer-events:none}
|
|
1116
|
+
.lgl a{color:inherit;text-decoration:underline;text-underline-offset:2px;pointer-events:auto}
|
|
1117
|
+
.lgl a:hover{color:rgba(255,255,255,.8)}
|
|
1118
|
+
.lgl-sep{margin:0 6px;opacity:.5}
|
|
1119
|
+
.lgl-note{opacity:.9}
|
|
1120
|
+
@media (max-width:640px){ .lgl{font-size:10px;padding:4px 10px} }
|
|
1121
|
+
`;
|
|
1122
|
+
|
|
1123
|
+
function sendHtml(res, status, html, scriptSrc, imgExtra, frameAncestors) {
|
|
1124
|
+
res.statusCode = status;
|
|
1125
|
+
// Origine Supabase Storage (voix ElevenLabs mise en cache dans le bucket public tts-cache) → autorisée en media-src.
|
|
1126
|
+
let supaOrigin = ""; try { supaOrigin = new URL(process.env.SUPABASE_URL || "").origin; } catch { supaOrigin = ""; }
|
|
1127
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1128
|
+
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
1129
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1130
|
+
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
1131
|
+
res.setHeader("Content-Security-Policy", [
|
|
1132
|
+
"default-src 'none'",
|
|
1133
|
+
`script-src ${scriptSrc || "'none'"} https://cdnjs.cloudflare.com`,
|
|
1134
|
+
"worker-src 'self' blob: https://cdnjs.cloudflare.com",
|
|
1135
|
+
`connect-src 'self' https://cdnjs.cloudflare.com${supaOrigin ? " " + supaOrigin : ""}`,
|
|
1136
|
+
`img-src 'self' data: blob:${imgExtra ? " " + imgExtra : ""}`,
|
|
1137
|
+
`media-src 'self'${supaOrigin ? " " + supaOrigin : ""}`,
|
|
1138
|
+
"style-src 'unsafe-inline'",
|
|
1139
|
+
"base-uri 'none'",
|
|
1140
|
+
// Aperçu interne : framing MÊME ORIGINE autorisé (iframe DocViewer). Page publique : 'none' (anti-clickjacking).
|
|
1141
|
+
`frame-ancestors ${frameAncestors || "'none'"}`,
|
|
1142
|
+
].join("; "));
|
|
1143
|
+
res.end(html);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Page « soft wall » : CSP dédiée — autorise Google Identity Services (One-Tap) en plus du nonce.
|
|
1147
|
+
function sendSoftWallHtml(res, html, nonce, imgExtra, frameAncestors) {
|
|
1148
|
+
res.statusCode = 200;
|
|
1149
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1150
|
+
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
1151
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1152
|
+
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
1153
|
+
res.setHeader("Content-Security-Policy", [
|
|
1154
|
+
"default-src 'none'",
|
|
1155
|
+
`script-src 'nonce-${nonce}' https://accounts.google.com/gsi/client`,
|
|
1156
|
+
"connect-src 'self' https://accounts.google.com/gsi/",
|
|
1157
|
+
"frame-src https://accounts.google.com/gsi/",
|
|
1158
|
+
`img-src 'self' data: blob:${imgExtra ? " " + imgExtra : ""}`,
|
|
1159
|
+
"style-src 'unsafe-inline' https://accounts.google.com/gsi/style",
|
|
1160
|
+
"base-uri 'none'",
|
|
1161
|
+
`frame-ancestors ${frameAncestors || "'self'"}`,
|
|
1162
|
+
].join("; "));
|
|
1163
|
+
res.end(html);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Ancres de framing d'une page INTÉGRÉE (?embed=1). Extrait du chemin nominal : une page de refus
|
|
1168
|
+
* doit pouvoir être encadrée exactement comme la visionneuse qu'elle remplace — sinon le
|
|
1169
|
+
* navigateur bloque le rendu et le message de refus ci-dessous n'est jamais émis.
|
|
1170
|
+
*/
|
|
1171
|
+
function embedFrameAncestors() {
|
|
1172
|
+
return ["'self'", "https://*.vercel.app"]
|
|
1173
|
+
.concat(String(process.env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean))
|
|
1174
|
+
.join(" ");
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* REFUS D'AFFICHER, dit à voix haute.
|
|
1179
|
+
*
|
|
1180
|
+
* ⚠️ Signalé par le second hôte en câblant son application : `embed-ready` qui n'arrive pas peut
|
|
1181
|
+
* vouloir dire deux choses OPPOSÉES — le player n'est pas là (instance absente, en panne), ou le
|
|
1182
|
+
* player REFUSE (lien révoqué, mur d'accès, greffon manquant en fail-closed). Un hôte prudent
|
|
1183
|
+
* replie sur le lecteur du navigateur au bout de quelques secondes ; dans le second cas, ce repli
|
|
1184
|
+
* OUVRE un document que le player venait de fermer. Le silence était donc un trou de sécurité.
|
|
1185
|
+
*
|
|
1186
|
+
* On répond `embed-denied` : la décision reste la nôtre, l'hôte apprend seulement à ne pas replier.
|
|
1187
|
+
*/
|
|
1188
|
+
function sendRefusal(res, reason, embed) {
|
|
1189
|
+
if (!embed) return sendHtml(res, 404, notFoundHtml());
|
|
1190
|
+
const nonce = crypto.randomBytes(16).toString("base64");
|
|
1191
|
+
const html = notFoundHtml() + `<script nonce="${nonce}">try{parent.postMessage({type:"3dd-doc-embed-denied",reason:${JSON.stringify(String(reason))}},"*")}catch(e){}</script>`;
|
|
1192
|
+
return sendHtml(res, 404, html, `'nonce-${nonce}'`, "", embedFrameAncestors());
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function notFoundHtml() {
|
|
1196
|
+
return `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Document indisponible</title><body style="font:15px/1.5 -apple-system,system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;background:#f3f1ed;color:#222"><div style="text-align:center"><div style="font-weight:800;font-size:18px;margin-bottom:6px">Document indisponible</div><div style="color:#777">Ce lien n'est plus valide ou a été révoqué.</div></div>`;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Page « soft wall » : accès à un document réservé (require_auth). On ne demande PAS un compte,
|
|
1200
|
+
// on propose de RECEVOIR le document — l'email est l'action pour débloquer, pas un péage.
|
|
1201
|
+
// Email → code à 6 chiffres → cookie posé → reload → le lecteur s'ouvre. (Google = Lot B.)
|
|
1202
|
+
function softWallHtml(share, nonce, logoUrl, googleClientId) {
|
|
1203
|
+
const title = esc(share.doc_title || share.file_name || "ce document");
|
|
1204
|
+
const brandLogo = esc(share.brand_logo || "");
|
|
1205
|
+
const dark = !!share.brand_dark;
|
|
1206
|
+
const logo = brandLogo || esc(logoUrl || "");
|
|
1207
|
+
const logoAlt = brandLogo ? esc(share.brand_name || "") : "";
|
|
1208
|
+
const poweredBy = !!brandLogo; // logo promoteur → mention 3DD dessous
|
|
1209
|
+
const gcid = esc(googleClientId || "");
|
|
1210
|
+
return `<!doctype html><html lang=fr><head><meta charset=utf-8>
|
|
1211
|
+
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
1212
|
+
<meta name=robots content="noindex,nofollow"><link rel=icon href="data:,">
|
|
1213
|
+
<title>Accès — ${title}</title>
|
|
1214
|
+
<style>
|
|
1215
|
+
*{box-sizing:border-box}html,body{margin:0;height:100%}
|
|
1216
|
+
body{font:15px/1.55 -apple-system,system-ui,Segoe UI,Roboto,sans-serif;color:#1c1a17;
|
|
1217
|
+
background:${dark ? "#16181d" : "radial-gradient(120% 90% at 20% 0%,#efe9df 0%,#e7e0d4 55%,#ddd4c4 100%)"};
|
|
1218
|
+
display:flex;align-items:center;justify-content:center;padding:24px}
|
|
1219
|
+
.card{width:100%;max-width:420px;background:#fff;border-radius:20px;padding:34px 32px 26px;
|
|
1220
|
+
box-shadow:0 24px 70px rgba(30,22,12,.20);text-align:center}
|
|
1221
|
+
.logo{max-height:44px;max-width:190px;margin:0 auto 22px;display:block;object-fit:contain}
|
|
1222
|
+
h1{font-size:20px;font-weight:800;letter-spacing:-.02em;margin:0 0 6px}
|
|
1223
|
+
.sub{font-size:13.5px;color:#7c7266;margin:0 0 22px}
|
|
1224
|
+
.doc{font-weight:700;color:#1c1a17}
|
|
1225
|
+
label{display:block;text-align:left;font-size:12px;font-weight:700;color:#3a352e;margin:0 0 6px}
|
|
1226
|
+
input{width:100%;padding:13px 14px;border:1px solid #ddd4c6;border-radius:12px;font:inherit;font-size:15px;background:#fbf9f6;outline:none;transition:border-color .15s,box-shadow .15s}
|
|
1227
|
+
input:focus{border-color:#c8996a;box-shadow:0 0 0 3px #c8996a22}
|
|
1228
|
+
.btn{width:100%;margin-top:14px;padding:14px;border:0;border-radius:12px;font:inherit;font-size:15px;font-weight:700;color:#fff;background:#1c1a17;cursor:pointer;transition:transform .06s,opacity .15s}
|
|
1229
|
+
.btn:active{transform:scale(.985)}.btn:disabled{opacity:.5;cursor:default}
|
|
1230
|
+
.step2{display:none}.step2.on{display:block}
|
|
1231
|
+
.row2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
|
1232
|
+
.err{color:#c0392b;font-size:12.5px;min-height:16px;margin:10px 0 0}
|
|
1233
|
+
.ok{color:#177245}
|
|
1234
|
+
.legal{font-size:11px;color:#a79c8d;margin:16px 0 0;line-height:1.5}
|
|
1235
|
+
.pb{margin-top:16px;font-size:11px;color:#b3ab9d}
|
|
1236
|
+
.field{margin-top:14px}
|
|
1237
|
+
.gbtn{display:flex;justify-content:center;margin:2px 0 4px}
|
|
1238
|
+
.orsep{display:flex;align-items:center;gap:10px;color:#a79c8d;font-size:11.5px;margin:6px 0 12px}
|
|
1239
|
+
.orsep::before,.orsep::after{content:"";flex:1;height:1px;background:#e8e0d3}
|
|
1240
|
+
</style></head>
|
|
1241
|
+
<body>
|
|
1242
|
+
<div class=card>
|
|
1243
|
+
${logo ? `<img class=logo src="${logo}" alt="${logoAlt}">` : ""}
|
|
1244
|
+
<h1>Accédez à votre document</h1>
|
|
1245
|
+
<p class=sub><span class=doc>${title}</span><br>Débloquez-le en un instant.</p>
|
|
1246
|
+
|
|
1247
|
+
${gcid ? `<div id=gbtn class=gbtn></div><div class=orsep><span>ou par email</span></div>` : ""}
|
|
1248
|
+
<div id=s1>
|
|
1249
|
+
<div class=field><label>Votre email</label><input id=email type=email autocomplete=email inputmode=email placeholder="prenom@email.fr"></div>
|
|
1250
|
+
<button class=btn id=send>Recevoir mon accès</button>
|
|
1251
|
+
</div>
|
|
1252
|
+
|
|
1253
|
+
<div id=s2 class=step2>
|
|
1254
|
+
<div class=row2>
|
|
1255
|
+
<div><label>Votre nom</label><input id=name type=text autocomplete=name placeholder="Prénom Nom"></div>
|
|
1256
|
+
<div><label>Code reçu</label><input id=code type=text inputmode=numeric autocomplete=one-time-code maxlength=6 placeholder="123456"></div>
|
|
1257
|
+
</div>
|
|
1258
|
+
<button class=btn id=verify>Débloquer le document</button>
|
|
1259
|
+
</div>
|
|
1260
|
+
|
|
1261
|
+
<p class=err id=err></p>
|
|
1262
|
+
<p class=legal>Vos coordonnées permettent au conseiller de vous recontacter au sujet de ce projet. Désinscription possible à tout moment.</p>
|
|
1263
|
+
${poweredBy && PLAYER.branding.poweredBy ? `<p class=pb>Powered by ${esc(PLAYER.branding.poweredBy)}</p>` : ""}
|
|
1264
|
+
</div>
|
|
1265
|
+
${gcid ? `<script nonce="${nonce}" src="https://accounts.google.com/gsi/client" async></script>` : ""}
|
|
1266
|
+
<script nonce="${nonce}">
|
|
1267
|
+
var SLUG=${JSON.stringify(share.slug || "")};
|
|
1268
|
+
var GCID=${JSON.stringify(gcid)};
|
|
1269
|
+
var $=function(id){return document.getElementById(id);};
|
|
1270
|
+
function err(m){$('err').className='err';$('err').textContent=m||'';}
|
|
1271
|
+
function post(o){return fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)}).then(function(r){return r.json().then(function(j){return{s:r.status,j:j};});});}
|
|
1272
|
+
$('send').onclick=function(){
|
|
1273
|
+
var em=($('email').value||'').trim();
|
|
1274
|
+
if(!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(em)){err('Entrez un email valide.');return;}
|
|
1275
|
+
$('send').disabled=true;$('send').textContent='Envoi…';err('');
|
|
1276
|
+
post({action:'visitor-request',slug:SLUG,email:em}).then(function(r){
|
|
1277
|
+
$('send').disabled=false;$('send').textContent='Recevoir mon accès';
|
|
1278
|
+
if(r.j&&r.j.ok){$('s2').classList.add('on');$('err').className='err ok';$('err').textContent='Code envoyé à '+em+'. Vérifiez votre boîte mail.';$('code').focus();}
|
|
1279
|
+
else err(r.s===429?'Trop de demandes, réessayez plus tard.':'Envoi impossible, réessayez.');
|
|
1280
|
+
}).catch(function(){$('send').disabled=false;$('send').textContent='Recevoir mon accès';err('Erreur réseau.');});
|
|
1281
|
+
};
|
|
1282
|
+
$('verify').onclick=function(){
|
|
1283
|
+
var em=($('email').value||'').trim(),cd=($('code').value||'').trim(),nm=($('name').value||'').trim();
|
|
1284
|
+
if(!/^\\d{6}$/.test(cd)){err('Entrez le code à 6 chiffres.');return;}
|
|
1285
|
+
$('verify').disabled=true;$('verify').textContent='Vérification…';err('');
|
|
1286
|
+
post({action:'visitor-verify',slug:SLUG,email:em,code:cd,name:nm}).then(function(r){
|
|
1287
|
+
if(r.j&&r.j.ok){$('verify').textContent='Accès débloqué ✓';location.reload();}
|
|
1288
|
+
else{$('verify').disabled=false;$('verify').textContent='Débloquer le document';err(r.j&&r.j.error==='expiré'?'Code expiré, renvoyez-en un.':'Code incorrect.');}
|
|
1289
|
+
}).catch(function(){$('verify').disabled=false;$('verify').textContent='Débloquer le document';err('Erreur réseau.');});
|
|
1290
|
+
};
|
|
1291
|
+
$('code')&&$('code').addEventListener('keydown',function(e){if(e.key==='Enter')$('verify').click();});
|
|
1292
|
+
$('email')&&$('email').addEventListener('keydown',function(e){if(e.key==='Enter')$('send').click();});
|
|
1293
|
+
function onGoogle(resp){ if(!resp||!resp.credential){return;} err('');
|
|
1294
|
+
post({action:'visitor-google',slug:SLUG,credential:resp.credential}).then(function(r){
|
|
1295
|
+
if(r.j&&r.j.ok){location.reload();} else err('Connexion Google impossible, essayez par email.');
|
|
1296
|
+
}).catch(function(){err('Connexion Google impossible, essayez par email.');});
|
|
1297
|
+
}
|
|
1298
|
+
function initG(tries){ tries=tries||0;
|
|
1299
|
+
if(!window.google||!google.accounts||!google.accounts.id){ if(tries<40){return setTimeout(function(){initG(tries+1);},120);} return; }
|
|
1300
|
+
google.accounts.id.initialize({client_id:GCID,callback:onGoogle,auto_select:false});
|
|
1301
|
+
var w=Math.min(340,(document.querySelector('.card')||{}).clientWidth-64||320);
|
|
1302
|
+
google.accounts.id.renderButton($('gbtn'),{type:'standard',theme:'outline',size:'large',shape:'pill',text:'continue_with',logo_alignment:'center',width:w});
|
|
1303
|
+
try{google.accounts.id.prompt();}catch(e){}
|
|
1304
|
+
}
|
|
1305
|
+
if(GCID){initG(0);}
|
|
1306
|
+
</script>
|
|
1307
|
+
</body></html>`;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function viewerHtml(share, nonce, logoUrl, pitch) {
|
|
1311
|
+
const title = esc(share.doc_title || share.file_name || "Document");
|
|
1312
|
+
// Aperçu interne : slug vide (= pas de tracking, pas de bouton de re-partage) + stream depuis le bucket public.
|
|
1313
|
+
const preview = !!share.preview;
|
|
1314
|
+
// Assistant IA : greffon PRÉSENT côté serveur ET activé sur ce lien. Sans le greffon, la colonne
|
|
1315
|
+
// `bot_enabled` restait vraie en base → on injectait le style, le balisage et 116 Ko de script
|
|
1316
|
+
// d'un assistant qui n'aurait jamais répondu.
|
|
1317
|
+
const botOn = !preview && !!share.bot_enabled && !!docbot && !!botBrowser;
|
|
1318
|
+
// INTÉGRÉ (?embed=1) : la visionneuse est en surimpression dans une page hôte de MÊME
|
|
1319
|
+
// ORIGINE (le plan d'un lot dans une expérience 3D). L'hôte n'a alors plus besoin de sa
|
|
1320
|
+
// propre barre de titre : celle-ci porte la croix, et la sortie remonte en postMessage.
|
|
1321
|
+
const embed = !preview && !!share.embed;
|
|
1322
|
+
const fileUrl = preview ? share.stream_url : `/api/doc?slug=${encodeURIComponent(share.slug)}&file=1`;
|
|
1323
|
+
// Logo de MARQUE du loader : celui du promoteur (brand_logo, ex. MJ
|
|
1324
|
+
// Développement) s'il est renseigné, avec la mention de l'éditeur dessous (si configurée)
|
|
1325
|
+
// dessous ; SINON l'intro animée de la marque (api/_brand-intro.js), qui
|
|
1326
|
+
// remplace l'ancien wordmark statique (et son grand vide sans logo).
|
|
1327
|
+
const brandLogo = esc(share.brand_logo || "");
|
|
1328
|
+
const brandDark = !!share.brand_dark; // fond sombre du loader (logo clair/blanc)
|
|
1329
|
+
// Texte de remplacement de l'image : ce que le lecteur voit si le logo ne charge pas. C'est
|
|
1330
|
+
// TOUT l'intérêt de `name` — un logo cassé laisse sinon un vide à la place d'une marque.
|
|
1331
|
+
const brandName = esc(share.brand_name || "");
|
|
1332
|
+
// En aperçu interne, on embarque de quoi démarrer une présentation live (URL Storage brute + métadonnées).
|
|
1333
|
+
// `fileName` : c'est LUI qui dit la nature du document côté page. L'URL publique est
|
|
1334
|
+
// `/api/doc?slug=…&file=1`, sans extension — sans ce champ, une image partait dans pdf.js.
|
|
1335
|
+
const cfg = JSON.stringify({ brand: PLAYER.branding.name, slug: preview ? "" : share.slug, fileUrl, fileName: share.file_name || "", pdfjs: PDFJS, title, preview, embed, bot: botOn, botGuided: !preview && !!share.bot_enabled && share.bot_guided !== false, botAv: (!preview && share.bot_enabled && share.bot_avatar) || "", botName: (!preview && share.bot_enabled && share.bot_name) || "", botGreet: (!preview && share.bot_enabled && share.bot_greeting) || "", botGreetDoc: (!preview && share.bot_enabled && share.bot_greeting_doc) || "", dl: share.allow_download !== false, autoPresent: !!share.auto_present, botAnim: share.bot_page_anim !== false, botVoice: !preview && !!share.bot_enabled && !!process.env.ELEVENLABS_API_KEY, vIcOn: ICONS.sound, vIcOff: ICONS.mute, kStyle: (!preview && share.bot_enabled && share.bot_karaoke) || "classic", vLayout: (!preview && share.bot_enabled && share.video_layout) || "", vClips: !preview && !!share.bot_vclips, botVAv: (!preview && share.bot_enabled && share.bot_vphoto) || "", resumeSlug: preview ? (share.resume_slug || "") : "", supaUrl: preview ? (share.supa_url || "") : "", supaKey: preview ? (share.supa_key || "") : "", internal: preview && share.internal_email ? { email: share.internal_email, name: share.presenter_name || "", docId: share.doc_id || "" } : null, present: preview ? { url: share.raw_url || "", name: share.file_name || "", title: share.doc_title || "", docId: share.doc_id || "", by: share.presenter_name || "", email: share.internal_email || "", av: share.presenter_avatar || "" } : null });
|
|
1336
|
+
return `<!doctype html><html lang=fr><head><meta charset=utf-8>
|
|
1337
|
+
<meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=3,viewport-fit=cover,interactive-widget=resizes-content">
|
|
1338
|
+
<meta name=robots content="noindex,nofollow">
|
|
1339
|
+
<link rel=icon href="data:,">
|
|
1340
|
+
<link rel=preconnect href="https://cdnjs.cloudflare.com" crossorigin>
|
|
1341
|
+
<title>${esc(PLAYER.branding.title(share.doc_title || share.file_name || "Document"))}</title>
|
|
1342
|
+
<style>
|
|
1343
|
+
:root{--bg:#33312e;--bar:#26241f}
|
|
1344
|
+
*{box-sizing:border-box}
|
|
1345
|
+
html,body{margin:0;height:100%}
|
|
1346
|
+
body{background:var(--bg);font:14px/1.5 -apple-system,system-ui,Segoe UI,Roboto,sans-serif;color:#eee;display:flex;flex-direction:column}
|
|
1347
|
+
.bar{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--bar);border-bottom:1px solid #0003;flex:none}
|
|
1348
|
+
/* Header façon PLAYER VIDÉO : il FLOTTE en verre dépoli PAR-DESSUS le document (qui occupe l'écran
|
|
1349
|
+
jusqu'en haut) et s'escamote en 1,5 s — aucun espace réservé pour un élément visible 5 % du temps. */
|
|
1350
|
+
body.deskcap .bar,body.deskaudio .bar{position:fixed;top:0;left:0;right:0;z-index:34;transition:transform .32s,opacity .32s;background:rgba(20,18,14,.52);backdrop-filter:blur(14px) saturate(1.1);-webkit-backdrop-filter:blur(14px) saturate(1.1);border-bottom-color:#ffffff14}
|
|
1351
|
+
body.light.deskcap .bar,body.light.deskaudio .bar{background:rgba(247,245,241,.6);border-bottom-color:#00000012}
|
|
1352
|
+
body.barhide .bar{transform:translateY(-100%);opacity:0;pointer-events:none}
|
|
1353
|
+
body.deskcap.onepage #pages,body.deskaudio.onepage #pages{padding-top:12px} /* le doc monte jusqu'en haut — le header passe DESSUS */
|
|
1354
|
+
body.deskpresent #fs{display:none} /* plein écran BLOQUÉ pendant la présentation guidée (le fit est piloté) */
|
|
1355
|
+
/* Ligne de PROGRESSION (2px, tout en haut, au-dessus du header) : le repère silencieux des players
|
|
1356
|
+
vidéo — le prospect sait où il en est sans chercher « Page x/y » dans un header escamoté. */
|
|
1357
|
+
.pgline{display:none;position:fixed;top:0;left:0;right:0;height:2px;z-index:36;background:rgba(255,255,255,.16)}
|
|
1358
|
+
body.deskpresent .pgline{display:block}
|
|
1359
|
+
.pgline i{display:block;height:100%;width:0;background:#fff;opacity:.85;transition:width .5s ease}
|
|
1360
|
+
body.light .pgline{background:rgba(0,0,0,.14)}
|
|
1361
|
+
body.light .pgline i{background:#15130f;opacity:.8}
|
|
1362
|
+
@media(max-width:820px){.pgline{display:none !important} body.botplayer .pgline{display:block !important;z-index:44}}
|
|
1363
|
+
/* viewport-fit=cover : la barre ne passe pas sous l'encoche / la barre d'état (env()=0 ailleurs). */
|
|
1364
|
+
.bar{padding-top:calc(10px + env(safe-area-inset-top));padding-left:max(16px,env(safe-area-inset-left));padding-right:max(16px,env(safe-area-inset-right))}
|
|
1365
|
+
.bar b{font-size:14px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1366
|
+
.testchip{flex:none;font-size:11px;font-weight:800;letter-spacing:.03em;color:#15130f;background:#ffd66b;border-radius:999px;padding:3px 10px;white-space:nowrap}
|
|
1367
|
+
.bar .sp{flex:1}
|
|
1368
|
+
.pg{font-size:12.5px;color:#bbb;white-space:nowrap}
|
|
1369
|
+
.zoom{display:flex;align-items:center;gap:6px}
|
|
1370
|
+
.zoom button{width:27px;height:27px;border:1px solid #fff3;background:transparent;color:#fff;border-radius:7px;cursor:pointer;font-size:16px;line-height:0}
|
|
1371
|
+
.zoom button:hover{background:#fff2}
|
|
1372
|
+
.zoom span{font-size:12px;color:#bbb;min-width:40px;text-align:center}
|
|
1373
|
+
.dl{color:#fff;text-decoration:none;font:inherit;font-size:12.5px;border:1px solid #fff3;border-radius:8px;padding:6px 11px;background:transparent;cursor:pointer}
|
|
1374
|
+
.dl:hover{background:#fff2}
|
|
1375
|
+
.dl.primary{background:#fff;color:#1a1a1a;border-color:#fff;font-weight:600}
|
|
1376
|
+
.dl.primary:hover{filter:brightness(.93)}
|
|
1377
|
+
.ic{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:1px solid #fff3;background:transparent;color:#fff;border-radius:8px;cursor:pointer;padding:0;font-size:15px;line-height:0}
|
|
1378
|
+
.ic:hover{background:#fff2}
|
|
1379
|
+
.ic svg{width:16px;height:16px;display:block}
|
|
1380
|
+
/* Croix du mode INTÉGRÉ : elle OUVRE la barre, à gauche du titre — la place où l'œil
|
|
1381
|
+
cherche une sortie. Sans bordure : c'est une sortie, pas une action de plus. Elle
|
|
1382
|
+
reste visible sous 520px, où le titre s'efface (.bar>b{display:none}). */
|
|
1383
|
+
.barx{border-color:transparent;margin-right:-4px;flex:none}
|
|
1384
|
+
.scroll{flex:1;overflow:auto;position:relative}
|
|
1385
|
+
#pages{display:flex;flex-direction:column;align-items:center;gap:16px;width:max-content;min-width:100%;margin:0 auto;padding:22px 14px}
|
|
1386
|
+
.page{position:relative;background:#fff;box-shadow:0 6px 22px #0006;border-radius:3px}
|
|
1387
|
+
.page canvas{display:block;border-radius:3px}
|
|
1388
|
+
/* Couche texte pdf.js : invisible, superposée au canvas → sélection du texte possible (requiert --scale-factor). */
|
|
1389
|
+
.textLayer{position:absolute;inset:0;overflow:hidden;line-height:1;opacity:1;z-index:2;forced-color-adjust:none}
|
|
1390
|
+
.textLayer span,.textLayer br{color:transparent;position:absolute;white-space:pre;cursor:text;transform-origin:0 0}
|
|
1391
|
+
.textLayer ::selection{background:rgba(60,120,255,.4)}
|
|
1392
|
+
.ph{display:grid;place-items:center;color:#999;font-size:12px}
|
|
1393
|
+
${LEGAL_CSS}
|
|
1394
|
+
/* Loader (moderne) : couvre la zone, logo + barre de progression réelle, puis fondu. */
|
|
1395
|
+
#load{position:absolute;inset:0;background:#fff;display:flex;align-items:center;justify-content:center;z-index:6;transition:opacity .4s ease}
|
|
1396
|
+
#load.hide{opacity:0;pointer-events:none}
|
|
1397
|
+
.lbox{display:flex;flex-direction:column;align-items:center;gap:20px}
|
|
1398
|
+
.lbox img{height:40px;animation:lpulse 1.6s ease-in-out infinite}
|
|
1399
|
+
.lbox img.lbrand{height:56px;max-width:220px;object-fit:contain;margin-bottom:-12px}
|
|
1400
|
+
.lpowered{font-size:10.5px;color:#a8a29a;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
|
|
1401
|
+
#load.ldark{background:#15130f}
|
|
1402
|
+
#load.ldark .lpowered{color:#8a857c}
|
|
1403
|
+
#load.ldark .lpct{color:#9c968c}
|
|
1404
|
+
#load.ldark .lbar{background:#2a2620}
|
|
1405
|
+
.lword{font-weight:800;font-size:23px;color:#15130f;letter-spacing:-.02em;animation:lpulse 1.6s ease-in-out infinite}
|
|
1406
|
+
/* Intro animée de la marque (sans logo promoteur) : elle occupe tout le cadre — le vol a
|
|
1407
|
+
toute la largeur, la marque atterrit au centre — et la barre passe en bas. Le wordmark
|
|
1408
|
+
statique reste dedans comme repli (JS indisponible) ; createBrandIntro le remplace. */
|
|
1409
|
+
.lintro{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
|
|
1410
|
+
#load.has-intro .lbox{position:absolute;left:0;right:0;bottom:13%;gap:12px}
|
|
1411
|
+
#load.ldark .lword{color:#fff}
|
|
1412
|
+
@keyframes lpulse{0%,100%{opacity:.5}50%{opacity:1}}
|
|
1413
|
+
.lbar{position:relative;width:200px;height:4px;background:#ececec;border-radius:999px;overflow:hidden}
|
|
1414
|
+
.lbar i{position:absolute;left:0;top:0;height:100%;width:10%;background:linear-gradient(90deg,#15130f,#6b6457);border-radius:999px;transition:width .25s ease}
|
|
1415
|
+
/* balayage « tech » tant qu'on ne connaît pas encore le total */
|
|
1416
|
+
.lbar.idle i{width:36%;animation:lsweep 1.1s ease-in-out infinite}
|
|
1417
|
+
@keyframes lsweep{0%{left:-36%}100%{left:100%}}
|
|
1418
|
+
.lpct{font-size:11px;color:#8a857c;letter-spacing:.04em;text-transform:uppercase;font-weight:600}
|
|
1419
|
+
.lerr{color:#c0392b;font-size:13px}
|
|
1420
|
+
/* Filigrane discret (libère la barre du bas) — masqué sur mobile (chevauche le FAB/teaser, sans valeur). */
|
|
1421
|
+
.brand{position:fixed;right:13px;bottom:10px;font-size:10px;color:#fff;opacity:.42;pointer-events:none;letter-spacing:.02em;z-index:3}
|
|
1422
|
+
@media (max-width:820px){ .brand{display:none} }
|
|
1423
|
+
/* Popover de partage (forward) */
|
|
1424
|
+
.pop{position:fixed;top:52px;right:14px;width:308px;background:#fff;color:#1c1c1c;border-radius:14px;box-shadow:0 18px 54px rgba(0,0,0,.4);padding:15px;z-index:20;display:none}
|
|
1425
|
+
.pop.open{display:block}
|
|
1426
|
+
.pop h4{margin:0 0 3px;font-size:14px}
|
|
1427
|
+
.pop .h{margin:0 0 11px;font-size:11.5px;color:#777;line-height:1.4}
|
|
1428
|
+
.pop input{width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid #e4e0d9;border-radius:9px;font:inherit;font-size:13px;margin-bottom:8px;color:#1c1c1c}
|
|
1429
|
+
.pop .pbtn{width:100%;padding:9px;border:0;border-radius:9px;background:#111;color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
|
1430
|
+
.pop .pbtn:disabled{opacity:.5}
|
|
1431
|
+
.pop .pbtn2{width:100%;padding:8px;margin-top:7px;border:1px solid #e4e0d9;border-radius:9px;background:#fff;color:#555;font:inherit;font-size:12.5px;cursor:pointer}
|
|
1432
|
+
.pop .pbtn2:hover{background:#f6f4ef}
|
|
1433
|
+
.pop .msg{font-size:12.5px;color:#1f9254;font-weight:600;margin:0 0 8px}
|
|
1434
|
+
.pop .res{display:none;margin-top:11px}
|
|
1435
|
+
.pop .res.on{display:block}
|
|
1436
|
+
.prow{display:flex;gap:6px;margin-top:2px}
|
|
1437
|
+
.prow a,.prow button{flex:1;text-align:center;text-decoration:none;padding:8px;border-radius:9px;font-size:12.5px;font-weight:600;cursor:pointer;border:1px solid #e4e0d9;color:#1c1c1c;background:#f6f4ef}
|
|
1438
|
+
.pop .err{color:#c0392b;font-size:12px;margin:0 0 8px;display:none}
|
|
1439
|
+
.plive{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:#0a7d34;background:#e8f6ed;border-radius:9px;padding:9px 11px;margin:0 0 10px}
|
|
1440
|
+
.pdot{width:9px;height:9px;border-radius:50%;background:#15b85a;flex:none;animation:pdb 1.4s infinite}
|
|
1441
|
+
@keyframes pdb{0%{box-shadow:0 0 0 0 rgba(21,184,90,.5)}70%{box-shadow:0 0 0 7px rgba(21,184,90,0)}100%{box-shadow:0 0 0 0 rgba(21,184,90,0)}}
|
|
1442
|
+
/* Bandeau « présentation en direct » : fixe, persistant, en bas (ne gêne pas la barre). */
|
|
1443
|
+
.pbar{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:30;display:flex;align-items:center;gap:9px;max-width:94vw;padding:8px 9px 8px 15px;background:#fff;color:#1c1c1c;border-radius:999px;box-shadow:0 14px 46px rgba(0,0,0,.5)}
|
|
1444
|
+
.pbar-live{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:800;color:#e5384d;text-transform:uppercase;letter-spacing:.02em;white-space:nowrap;border:0;background:transparent;cursor:pointer;font-family:inherit;padding:4px 2px}
|
|
1445
|
+
.pbar-live i{width:9px;height:9px;border-radius:50%;background:#ff3b3b;animation:blink 1.4s infinite}
|
|
1446
|
+
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
|
|
1447
|
+
.pbar-chev{font-size:9px;opacity:.5;transition:transform .2s}
|
|
1448
|
+
.pbar.min .pbar-chev{transform:rotate(180deg)}
|
|
1449
|
+
.pbar.min{padding:6px 14px}
|
|
1450
|
+
.pbar.min>#pbarLink,.pbar.min>#pbarCopy,.pbar.min>#pbarSwitch,.pbar.min>#pbarMap,.pbar.min>#pbarInvite,.pbar.min>#pbarHandover,.pbar.min>#pbarEnd{display:none}
|
|
1451
|
+
.pbar-link{border:1px solid #e4e0d9;border-radius:8px;padding:6px 9px;font:inherit;font-size:12px;color:#555;width:240px;max-width:32vw;background:#f8f6f2}
|
|
1452
|
+
.pbar-btn{border:1px solid #e4e0d9;background:#f6f4ef;color:#1c1c1c;border-radius:8px;padding:7px 11px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;white-space:nowrap}
|
|
1453
|
+
.pbar-btn:hover{background:#eceae5}
|
|
1454
|
+
#pbarEnd{color:#c0392b}
|
|
1455
|
+
/* Barre d'outils responsive : sous 860px, zoom/plein écran/Présenter/Partager/Télécharger passent dans le
|
|
1456
|
+
menu « ⋯ » ; le titre se tronque puis disparaît. */
|
|
1457
|
+
.bar>b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:40vw}
|
|
1458
|
+
.barMore{display:none;font-size:19px}
|
|
1459
|
+
.barmenu{position:fixed;top:52px;right:12px;min-width:190px;background:#fff;color:#1c1c1c;border-radius:12px;box-shadow:0 18px 54px rgba(0,0,0,.4);padding:6px;z-index:45;display:none}
|
|
1460
|
+
.barmenu.open{display:block}
|
|
1461
|
+
.barmenu button{display:block;width:100%;text-align:left;border:0;background:transparent;font:inherit;font-size:13.5px;color:#1c1c1c;padding:9px 12px;border-radius:8px;cursor:pointer}
|
|
1462
|
+
.barmenu button:hover{background:#f2efe9}
|
|
1463
|
+
@media (max-width:860px){
|
|
1464
|
+
.bar .zoom,#fs,#presentBtn,#shareBtn,#dlBtn{display:none}
|
|
1465
|
+
.barMore{display:inline-flex}
|
|
1466
|
+
}
|
|
1467
|
+
@media (max-width:520px){ .bar>b{display:none} }
|
|
1468
|
+
/* Structure doc+colonne : TOUJOURS émise — un lien sans bot ni live n'avait ni LIVE_CSS ni BOT_CSS
|
|
1469
|
+
→ .lrow/.lmain sans flex, la fenêtre scrollait à la place du conteneur et le rendu paresseux mourait. */
|
|
1470
|
+
.lrow{flex:1;display:flex;min-height:0;position:relative}
|
|
1471
|
+
.lmain{flex:1;min-width:0;display:flex;flex-direction:column;position:relative}
|
|
1472
|
+
${preview ? LIVE_CSS : ""}
|
|
1473
|
+
${preview ? MAP_CSS : ""}
|
|
1474
|
+
${botOn ? BOT_CSS : ""}
|
|
1475
|
+
</style></head>
|
|
1476
|
+
<body>
|
|
1477
|
+
<div class=bar>${embed ? '<button class="ic barx" id=embedCloseBtn title=Fermer aria-label=Fermer><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>' : ""}<b>${title}</b>${share.is_test ? '<span class=testchip>\ud83c\udfad Répétition — session de test</span>' : ""}<span class=sp></span><span class=pg id=pg></span>
|
|
1478
|
+
${preview ? LIVE_BAR : ""}
|
|
1479
|
+
<div class=zoom><button id=zout title="Dézoomer">−</button><span id=zlbl>100%</span><button id=zin title="Zoomer">+</button></div>
|
|
1480
|
+
<button class=ic id=fs title="Plein écran"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg></button>
|
|
1481
|
+
${preview ? '<button class=dl id=presentBtn title="Présenter en direct (lien public, page synchronisée)">Présenter</button>' : ''}
|
|
1482
|
+
<button class="dl primary" id=shareBtn>Partager</button>
|
|
1483
|
+
${share.allow_download === false ? "" : `<a class=dl id=dlBtn href="${fileUrl}" download>Télécharger</a>`}<button class="ic barMore" id=barMore title="Plus d'actions">⋯</button>${preview ? '<button class=ic id=closeBtn title="Fermer"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>' : ''}</div>
|
|
1484
|
+
<div class=barmenu id=barMenu>
|
|
1485
|
+
<button data-t=zin>Zoom avant</button>
|
|
1486
|
+
<button data-t=zout>Zoom arrière</button>
|
|
1487
|
+
<button data-t=fs>Plein écran</button>
|
|
1488
|
+
${preview ? '<button data-t=presentBtn>Présenter en direct</button>' : ''}
|
|
1489
|
+
<button data-t=shareBtn>Partager</button>
|
|
1490
|
+
${share.allow_download === false ? "" : "<button data-t=dlBtn>Télécharger</button>"}
|
|
1491
|
+
</div>
|
|
1492
|
+
${preview ? `<div class=pbar id=pbar style="display:none">
|
|
1493
|
+
<button class=pbar-live id=pbarToggle title="Replier / déplier"><i></i> En direct <span class=pbar-chev>▾</span></button>
|
|
1494
|
+
<input class=pbar-link id=pbarLink readonly>
|
|
1495
|
+
<button class=pbar-btn id=pbarCopy>Copier</button>
|
|
1496
|
+
<button class=pbar-btn id=pbarSwitch>Changer de document</button>
|
|
1497
|
+
<button class=pbar-btn id=pbarMap>Afficher une carte</button>
|
|
1498
|
+
<button class=pbar-btn id=pbarInvite>Inviter l'équipe</button>
|
|
1499
|
+
<button class=pbar-btn id=pbarHandover>Passer la main</button>
|
|
1500
|
+
<button class=pbar-btn id=pbarEnd>Terminer</button>
|
|
1501
|
+
</div>` : ""}
|
|
1502
|
+
<div class=pop id=pop>
|
|
1503
|
+
<h4>Transmettre le document</h4>
|
|
1504
|
+
<p class=h>Envoyez-le à un contact par email — il recevra un lien dédié, à votre nom.</p>
|
|
1505
|
+
<p class=err id=shErr></p>
|
|
1506
|
+
<input id=shEmail type=email placeholder="email du destinataire" autocomplete=off>
|
|
1507
|
+
<input id=shName placeholder="nom (facultatif)" autocomplete=off>
|
|
1508
|
+
<button class=pbtn id=shSend>Envoyer le document</button>
|
|
1509
|
+
<button class=pbtn2 id=shSelf>Envoyer depuis ma messagerie</button>
|
|
1510
|
+
<div class=res id=shRes>
|
|
1511
|
+
<div class=msg id=shMsg></div>
|
|
1512
|
+
<input id=shLink readonly>
|
|
1513
|
+
<div class=prow><button id=shCopy>Copier le lien</button><a id=shMail style="display:none">Ouvrir l'email</a></div>
|
|
1514
|
+
</div>
|
|
1515
|
+
</div>
|
|
1516
|
+
<div class=lrow>
|
|
1517
|
+
<div class=lmain>
|
|
1518
|
+
<div class=scroll id=scroll>
|
|
1519
|
+
<div id=pages></div>
|
|
1520
|
+
<div id=load class="${brandDark ? "ldark" : ""}${brandLogo ? "" : " has-intro"}">${brandLogo || !PLAYER.branding.loaderName ? "" : `<div class=lintro id=lintro data-theme="${brandDark ? "dark" : "light"}"><div class=lword>${esc(PLAYER.branding.loaderName)}</div></div>`}<div class=lbox>${brandLogo ? `<img class=lbrand src="${brandLogo}" alt="${brandName}">${PLAYER.branding.poweredBy ? `<div class=lpowered>Powered by ${esc(PLAYER.branding.poweredBy)}</div>` : ""}` : ""}<div class="lbar idle" id=lbar><i id=lbarFill></i></div><div class=lpct id=lpct>Chargement…</div></div></div>
|
|
1521
|
+
</div>
|
|
1522
|
+
${share.bot_enabled ? `<button class="op-arrow op-prev" id=opPrev title="Page précédente" aria-label="Page précédente">${ICONS.prev}</button><button class="op-arrow op-next" id=opNext title="Page suivante" aria-label="Page suivante">${ICONS.next}</button><div class=dcap id=dcap><span class=dcap-av>${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : esc(String(share.bot_name || "◆").trim().charAt(0).toUpperCase())}</span><div class=dcap-body><div id=dcapT></div></div></div><div class=dkov id=dkov><div class=dkov-card><button class=dkov-big id=dkovBig aria-label="Reprendre la présentation">${ICONS.play}</button><div class=dkov-opts id=dkovOpts></div></div></div><div class=rateov id=rateov><div class=rate-card><button class=rate-x id=rateX aria-label=Fermer>${ICONS.close}</button><b>Votre avis compte</b><p>Comment avez-vous trouvé cette présentation ?</p><div class=rate-stars id=rateStars></div><span class=rate-thx>Merci pour votre retour \ud83d\ude4f</span></div></div><div class=qov id=qov><div class=qov-card id=qovCard></div></div><div class=byeov id=byeov><div class=bye-card><span class=bye-av>${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : esc(String(share.bot_name || "◆").trim().charAt(0).toUpperCase())}</span><b id=byeT>Merci !</b><p id=byeS>Et à très bientôt</p></div></div><div class=pgline id=pgline><i id=pglineF></i></div>` : ""}
|
|
1523
|
+
${preview ? MAP_MARKUP : ""}
|
|
1524
|
+
</div>
|
|
1525
|
+
${preview ? LIVE_PANEL : ""}
|
|
1526
|
+
${botOn ? botMarkup(share, pitch) : ""}
|
|
1527
|
+
</div>
|
|
1528
|
+
${PLAYER.branding.poweredBy ? `<div class=brand>Propulsé par ${esc(PLAYER.branding.poweredBy)}</div>` : ""}
|
|
1529
|
+
${legalFooter({ tracked: !preview && !!share.slug })}
|
|
1530
|
+
${brandLogo || !brandIntroRuntime ? "" : `<script nonce="${nonce}">(${brandIntroRuntime.toString()})();</script>`}
|
|
1531
|
+
<script nonce="${nonce}">${PLAYER_BROWSER_JS}</script>
|
|
1532
|
+
<script nonce="${nonce}" src="${PDFJS}/pdf.min.js"></script>
|
|
1533
|
+
${preview ? `<script nonce="${nonce}" src="${SUPAJS}"></script>
|
|
1534
|
+
<script nonce="${nonce}">var LIVECFG={supaUrl:${JSON.stringify(share.supa_url || "")},supaKey:${JSON.stringify(share.supa_key || "")}};var GMAPS_KEY=${JSON.stringify(process.env.GOOGLE_MAPS_API_KEY || "")};${LIVE_JS}
|
|
1535
|
+
${MAP_JS}</script>` : ""}
|
|
1536
|
+
<script nonce="${nonce}">
|
|
1537
|
+
(function(){
|
|
1538
|
+
var CFG=${cfg.replace(/</g, "\\u003c")};
|
|
1539
|
+
var cur=0, numPages=0;
|
|
1540
|
+
// Nature du document : le nom de fichier fait foi (le type MIME n'est pas toujours
|
|
1541
|
+
// renvoyé par le stockage). Une image = une page, sans pdf.js.
|
|
1542
|
+
var IS_IMG=Player.viewer.isImageDocument(CFG.fileName,CFG.fileUrl);
|
|
1543
|
+
var imgSrc='';
|
|
1544
|
+
var scrollEl=document.getElementById('scroll'), pagesEl=document.getElementById('pages');
|
|
1545
|
+
// Suivi de lecture (temps réel à l'écran par page, page la plus loin atteinte, session) :
|
|
1546
|
+
// player/src/tracking.ts — typé et testé, y compris la séparation prospect / aperçu interne.
|
|
1547
|
+
var T=Player.tracking.createTracker({ slug:CFG.slug||'', internal:CFG.internal||null, scrollElement:scrollEl });
|
|
1548
|
+
// POIGNÉE DE VISIONNEUSE : le contrat offert aux greffons — et la seule chose qu'ils
|
|
1549
|
+
// connaissent du lecteur. Les fonctions et éléments sont STABLES ; la page courante, le nombre
|
|
1550
|
+
// de pages, le document et le mode une-page sont des ACCESSEURS (ils changent pendant la
|
|
1551
|
+
// lecture, les figer donnerait un assistant qui commente la mauvaise page).
|
|
1552
|
+
var VIEWER={cfg:CFG,scrollEl:scrollEl,pagesEl:pagesEl,isLand:isLand,showPage:showPage,
|
|
1553
|
+
enterOnePage:enterOnePage,exitOnePage:exitOnePage,build:build,scrollToPage:scrollToPage,
|
|
1554
|
+
vsplitTint:vsplitTint,
|
|
1555
|
+
get cur(){return cur;},get numPages(){return numPages;},get pdfDoc(){return pdfDoc;},
|
|
1556
|
+
get onePage(){return onePage;},
|
|
1557
|
+
get soloOffered(){return soloOffered;},set soloOffered(v){soloOffered=!!v;}};
|
|
1558
|
+
var pdfDoc=null, zoom=1, firstAspect=1.35, rendered={}, io=null, ioCur=null;
|
|
1559
|
+
var onePage=false, soloOffered=false; // mode « une seule page » (présentation guidée) + offre de découverte solo
|
|
1560
|
+
function setCur(p){ if(!p||p===cur)return; cur=p; T.setPage(p); try{ if(window.__soloEnd)window.__soloEnd(p); }catch(e){} var pg=document.getElementById('pg'); if(pg&&numPages)pg.textContent='Page '+p+' / '+numPages; if(PRES) pushPage(); }
|
|
1561
|
+
// Bouton « Partager » : re-partage tracé (forward) → crée un lien ENFANT et propose de l'envoyer par email.
|
|
1562
|
+
function wireShare(){
|
|
1563
|
+
var pop=document.getElementById('pop'), btn=document.getElementById('shareBtn'), err=document.getElementById('shErr');
|
|
1564
|
+
btn.addEventListener('click',function(e){ e.stopPropagation(); pop.classList.toggle('open'); });
|
|
1565
|
+
pop.addEventListener('click',function(e){ e.stopPropagation(); });
|
|
1566
|
+
document.addEventListener('click',function(){ pop.classList.remove('open'); });
|
|
1567
|
+
function doReshare(send,b){
|
|
1568
|
+
var em=document.getElementById('shEmail').value.trim(), nm=document.getElementById('shName').value.trim();
|
|
1569
|
+
err.style.display='none';
|
|
1570
|
+
if(!/.+@.+[.].+/.test(em)){ err.textContent='Indiquez un email valide.'; err.style.display='block'; return; }
|
|
1571
|
+
var old=b.textContent; b.disabled=true; b.textContent='…';
|
|
1572
|
+
fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'reshare',slug:CFG.slug,email:em,name:nm,send:send})})
|
|
1573
|
+
.then(function(r){return r.json();}).then(function(d){
|
|
1574
|
+
b.disabled=false; b.textContent=old;
|
|
1575
|
+
if(!d||!d.ok||!d.slug){ err.textContent=(d&&d.message)||"Impossible de créer le lien."; err.style.display='block'; return; }
|
|
1576
|
+
var url=location.origin+'/doc/'+d.slug;
|
|
1577
|
+
document.getElementById('shLink').value=url;
|
|
1578
|
+
var mailA=document.getElementById('shMail');
|
|
1579
|
+
mailA.href='mailto:'+encodeURIComponent(em)+'?subject='+encodeURIComponent(CFG.title+(CFG.brand?' — '+CFG.brand:''))+'&body='+encodeURIComponent('Bonjour,\\n\\nVoici le document : '+url+'\\n\\nBien à vous,');
|
|
1580
|
+
var msg=document.getElementById('shMsg');
|
|
1581
|
+
if(send&&d.sent){ msg.textContent='✓ Document envoyé à '+em+'.'; mailA.style.display='none'; }
|
|
1582
|
+
else if(send){ msg.textContent='Lien créé (envoi auto indisponible).'; mailA.style.display=''; }
|
|
1583
|
+
else { msg.textContent='Lien créé.'; mailA.style.display=''; }
|
|
1584
|
+
document.getElementById('shRes').classList.add('on');
|
|
1585
|
+
}).catch(function(){ b.disabled=false; b.textContent=old; err.textContent='Erreur réseau.'; err.style.display='block'; });
|
|
1586
|
+
}
|
|
1587
|
+
document.getElementById('shSend').addEventListener('click',function(){ doReshare(true,this); });
|
|
1588
|
+
document.getElementById('shSelf').addEventListener('click',function(){ doReshare(false,this); });
|
|
1589
|
+
document.getElementById('shCopy').addEventListener('click',function(){ var i=document.getElementById('shLink'); i.select(); var b=this; try{ navigator.clipboard.writeText(i.value); b.textContent='Copié !'; setTimeout(function(){b.textContent='Copier le lien';},1500); }catch(e){} });
|
|
1590
|
+
}
|
|
1591
|
+
// Plein écran (depuis l'iframe → nécessite allow="fullscreen" côté hôte).
|
|
1592
|
+
var _fs=document.getElementById('fs');
|
|
1593
|
+
if(_fs) _fs.addEventListener('click',function(){ try{ if(document.fullscreenElement){document.exitFullscreen();} else if(document.documentElement.requestFullscreen){document.documentElement.requestFullscreen();} }catch(e){} });
|
|
1594
|
+
document.addEventListener('fullscreenchange',function(){ if(window.__refit)setTimeout(window.__refit,120); }); // la page suit la nouvelle taille (hors présentation, où #fs est masqué)
|
|
1595
|
+
// Menu « ⋯ » (barre responsive) : les items relaient un clic sur les VRAIS boutons de la barre (masqués sur mobile).
|
|
1596
|
+
var _bm=document.getElementById('barMore'), _bmenu=document.getElementById('barMenu');
|
|
1597
|
+
if(_bm&&_bmenu){
|
|
1598
|
+
_bm.addEventListener('click',function(e){ e.stopPropagation(); _bmenu.classList.toggle('open'); });
|
|
1599
|
+
_bmenu.addEventListener('click',function(e){ e.stopPropagation(); });
|
|
1600
|
+
document.addEventListener('click',function(){ _bmenu.classList.remove('open'); });
|
|
1601
|
+
var mb=_bmenu.querySelectorAll('button'); for(var i=0;i<mb.length;i++){ mb[i].addEventListener('click',function(){ var el=document.getElementById(this.getAttribute('data-t')); _bmenu.classList.remove('open'); if(el) el.click(); }); }
|
|
1602
|
+
}
|
|
1603
|
+
// Présentation live — GÉRÉE DANS L'IFRAME (toujours servie fraîche, en-tête no-store) → robuste même si le
|
|
1604
|
+
// bundle React de l'app est en cache. Bandeau fixe persistant en bas : lien + copier + inviter + terminer.
|
|
1605
|
+
// Seul l'envoi par chat (Inviter) est délégué à l'app (postMessage) car il exige le JWT.
|
|
1606
|
+
var PRES=null, _pushT=null, _hbIv=0;
|
|
1607
|
+
// JWT de la session app (MÊME ORIGINE, localStorage) → autorise le rattachement de la présentation au membre
|
|
1608
|
+
// (reprise / liste / transfert) et les actions authentifiées (reclaim).
|
|
1609
|
+
function appToken(){ try{ var raw=localStorage.getItem('3dd-supabase-auth'); if(!raw)return''; var s=JSON.parse(raw); var t=(s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||''; return t; }catch(e){ return ''; } }
|
|
1610
|
+
function ctlKey(slug){ return '3dd-pres-ctl-'+slug; }
|
|
1611
|
+
function saveCtl(slug,control){ try{ localStorage.setItem(ctlKey(slug),control); }catch(e){} }
|
|
1612
|
+
function clearCtl(slug){ try{ localStorage.removeItem(ctlKey(slug)); }catch(e){} }
|
|
1613
|
+
// Heartbeat : signale que la présentation est vivante (sinon marquée « orpheline » côté serveur au bout de 3 min).
|
|
1614
|
+
function startHb(){ clearInterval(_hbIv); _hbIv=setInterval(function(){ if(!PRES){ clearInterval(_hbIv); return; } fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-touch',slug:PRES.slug,control:PRES.control})}).catch(function(){}); },30000); }
|
|
1615
|
+
function showBar(slug){ var lk=document.getElementById('pbarLink'); if(lk) lk.value=location.origin+'/present/'+slug; var pb=document.getElementById('pbar'); if(pb) pb.style.display='flex'; }
|
|
1616
|
+
// Le présentateur DIFFUSE l'état qu'il vient de persister. La base reste la vérité (les
|
|
1617
|
+
// arrivants tardifs la relisent) ; la diffusion évite à l'audience de lire la table.
|
|
1618
|
+
function diffuserEtat(extra){ if(!PRES||!window.Live)return; try{ Live.sendState(Object.assign({active:true,current_page:cur||1,file_url:CFG.present&&CFG.present.url||null,updated_at:new Date().toISOString()},extra||{})); }catch(e){} }
|
|
1619
|
+
function pushPage(){ if(!PRES)return; clearTimeout(_pushT); _pushT=setTimeout(function(){ if(!PRES)return; diffuserEtat({current_page:cur||1}); fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-page',slug:PRES.slug,control:PRES.control,page:cur||1})}).catch(function(){}); },250); }
|
|
1620
|
+
function endPresent(){ if(!PRES)return; diffuserEtat({active:false}); var p=PRES; PRES=null; clearInterval(_hbIv); clearCtl(p.slug); var pb=document.getElementById('pbar'); if(pb)pb.style.display='none'; try{ if(window.Live) Live.disconnect(); }catch(e){} try{ var b=JSON.stringify({action:'present-end',slug:p.slug,control:p.control}); if(navigator.sendBeacon){navigator.sendBeacon('/api/doc',new Blob([b],{type:'application/json'}));} else {fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:b,keepalive:true});} }catch(e){} }
|
|
1621
|
+
// Transfert : je passe la main → je cesse de piloter SANS clôturer la présentation (le nouvel owner reprendra).
|
|
1622
|
+
function stopPilotingLocally(){ if(!PRES)return; var s=PRES.slug; PRES=null; clearInterval(_hbIv); clearCtl(s); var pb=document.getElementById('pbar'); if(pb)pb.style.display='none'; try{ if(window.Live) Live.disconnect(); }catch(e){} }
|
|
1623
|
+
function liveConnect(slug,control){ try{ if(window.Live){ var P=CFG.present||{}; Live.connect(slug,{name:P.by||'Présentateur',email:P.email||'',avatar:P.av||'',role:'presenter',member:true},control); } }catch(e){} }
|
|
1624
|
+
function startPresent(){
|
|
1625
|
+
if(PRES) return; var b=CFG.present; if(!b||!b.url) return;
|
|
1626
|
+
var btn=document.getElementById('presentBtn'); if(btn){ btn.disabled=true; btn.textContent='…'; }
|
|
1627
|
+
var h={'Content-Type':'application/json'}; var tk=appToken(); if(tk) h['Authorization']='Bearer '+tk;
|
|
1628
|
+
fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-start',fileUrl:b.url,fileName:b.name,docTitle:b.title,docId:b.docId,presenterName:b.by,presenterAvatar:b.av})})
|
|
1629
|
+
.then(function(r){return r.json();}).then(function(d){
|
|
1630
|
+
if(btn){ btn.disabled=false; btn.textContent='Présenter'; }
|
|
1631
|
+
if(!d||!d.ok||!d.slug) return;
|
|
1632
|
+
PRES={slug:d.slug,control:d.control}; saveCtl(d.slug,d.control);
|
|
1633
|
+
showBar(d.slug); liveConnect(d.slug,d.control); startHb(); pushPage();
|
|
1634
|
+
}).catch(function(){ if(btn){ btn.disabled=false; btn.textContent='Présenter'; } });
|
|
1635
|
+
}
|
|
1636
|
+
// Reprise : le membre propriétaire re-génère un control_token frais (via JWT) → reprend le pilotage, la
|
|
1637
|
+
// présentation reste la même (même slug/audience), et on saute à la page en cours.
|
|
1638
|
+
function resumePresent(slug){
|
|
1639
|
+
if(PRES||!slug) return;
|
|
1640
|
+
var h={'Content-Type':'application/json'}; var tk=appToken(); if(tk) h['Authorization']='Bearer '+tk;
|
|
1641
|
+
fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-reclaim',slug:slug})})
|
|
1642
|
+
.then(function(r){return r.json();}).then(function(d){
|
|
1643
|
+
if(!d||!d.ok||!d.control){ if(d&&d.status===403){ Player.bridge.sendToHost({type:'present-denied'}); } return; }
|
|
1644
|
+
PRES={slug:slug,control:d.control}; saveCtl(slug,d.control);
|
|
1645
|
+
showBar(slug); liveConnect(slug,d.control); startHb();
|
|
1646
|
+
var target=Math.max(1,d.page||1);
|
|
1647
|
+
var tries=0; (function jump(){ var el=(document.getElementById('pages')||document).querySelector('.page[data-p="'+target+'"]'); if(el){ el.scrollIntoView({block:'start'}); } else if(tries++<40){ setTimeout(jump,150); } })();
|
|
1648
|
+
}).catch(function(){});
|
|
1649
|
+
}
|
|
1650
|
+
// Carte live : persiste le contenu (present-content, JWT) → l'audience bascule/suit via Realtime.
|
|
1651
|
+
function presentContent(content){ if(!PRES)return; diffuserEtat({content:content||null}); var h={'Content-Type':'application/json'}; var tk=appToken(); if(tk) h['Authorization']='Bearer '+tk; fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-content',slug:PRES.slug,content:content})}).catch(function(){}); }
|
|
1652
|
+
function showMap(){ if(!PRES||!window.Map3DD)return; var wrap=document.getElementById('mapWrap'); if(wrap&&wrap.classList.contains('on')){ Map3DD.enter(null,true,presentContent); return; } var init=Player.presentation.initialMapContent(); presentContent(init); Map3DD.enter(init,true,presentContent); }
|
|
1653
|
+
function hideMap(){ if(!window.Map3DD)return; Map3DD.exit(); presentContent(null); }
|
|
1654
|
+
// Mode INTÉGRÉ : la page hôte délègue sa barre de titre à celle-ci. On lui dit
|
|
1655
|
+
// qu'on est là — sans cette poignée de main elle ne peut pas savoir si la croix
|
|
1656
|
+
// existe (visionneuse d'une version antérieure) et garde la sienne par sécurité.
|
|
1657
|
+
// Le format du fil, le targetOrigin et la validation vivent dans player/src/bridge.ts.
|
|
1658
|
+
if(CFG.embed){
|
|
1659
|
+
Player.bridge.sendToHost({type:'embed-ready'});
|
|
1660
|
+
var _xb=document.getElementById('embedCloseBtn');
|
|
1661
|
+
if(_xb) _xb.addEventListener('click',function(){ Player.bridge.sendToHost({type:'close'}); });
|
|
1662
|
+
// Échap ferme aussi : dans une surimpression, c'est le geste attendu.
|
|
1663
|
+
document.addEventListener('keydown',function(e){ if(e.key==='Escape'){ Player.bridge.sendToHost({type:'close'}); } });
|
|
1664
|
+
}
|
|
1665
|
+
if(CFG.preview){
|
|
1666
|
+
// « Partager » et « Fermer » délégués à l'app (postMessage). « Présenter » géré localement.
|
|
1667
|
+
var _sb=document.getElementById('shareBtn'); if(_sb) _sb.addEventListener('click',function(){ Player.bridge.sendToHost({type:'share'}); });
|
|
1668
|
+
// Fermer le viewer NE clôture PAS la présentation (elle reste reprenable via le panneau « Présentations
|
|
1669
|
+
// en direct »). Seul « Terminer » (pbarEnd) coupe la vue de l'audience. Sans reprise sous 3 min → auto-purge.
|
|
1670
|
+
var _cb=document.getElementById('closeBtn'); if(_cb) _cb.addEventListener('click',function(){ Player.bridge.sendToHost({type:'close'}); });
|
|
1671
|
+
var _pb=document.getElementById('presentBtn'); if(_pb) _pb.addEventListener('click',startPresent);
|
|
1672
|
+
var _co=document.getElementById('pbarCopy'); if(_co) _co.addEventListener('click',function(){ var l=document.getElementById('pbarLink'); try{ l.select(); }catch(e){} try{ navigator.clipboard.writeText(l.value); _co.textContent='Copié !'; setTimeout(function(){_co.textContent='Copier';},1400); }catch(e){} });
|
|
1673
|
+
var _iv=document.getElementById('pbarInvite'); if(_iv) _iv.addEventListener('click',function(){ if(PRES){ Player.bridge.sendToHost({type:'present-invite',slug:PRES.slug}); } });
|
|
1674
|
+
var _en=document.getElementById('pbarEnd'); if(_en) _en.addEventListener('click',endPresent);
|
|
1675
|
+
// Bandeau Présenter : repli manuel (clic « En direct ») + repli AUTO au scroll du document (s'il est déplié).
|
|
1676
|
+
var _pt=document.getElementById('pbarToggle'); if(_pt) _pt.addEventListener('click',function(e){ e.stopPropagation(); var pb=document.getElementById('pbar'); if(pb)pb.classList.toggle('min'); });
|
|
1677
|
+
if(scrollEl) scrollEl.addEventListener('scroll',function(){ var pb=document.getElementById('pbar'); if(pb&&pb.style.display!=='none'&&!pb.classList.contains('min')) pb.classList.add('min'); },{passive:true});
|
|
1678
|
+
// Passer la main : le bandeau demande à l'app d'ouvrir le sélecteur de membre (le transfert exige le JWT).
|
|
1679
|
+
var _ho=document.getElementById('pbarHandover'); if(_ho) _ho.addEventListener('click',function(){ if(PRES){ Player.bridge.sendToHost({type:'present-handover',slug:PRES.slug}); } });
|
|
1680
|
+
// Changer de document : l'app ouvre le sélecteur (bibliothèque Documents, réservé aux membres) sans couper la session.
|
|
1681
|
+
var _sw=document.getElementById('pbarSwitch'); if(_sw) _sw.addEventListener('click',function(){ if(PRES){ Player.bridge.sendToHost({type:'present-switch',slug:PRES.slug}); } });
|
|
1682
|
+
// Carte live : afficher une carte (recherche + pan/zoom synchronisés) / revenir au document.
|
|
1683
|
+
var _mp=document.getElementById('pbarMap'); if(_mp) _mp.addEventListener('click',showMap);
|
|
1684
|
+
var _mb=document.getElementById('mapBack'); if(_mb) _mb.addEventListener('click',hideMap);
|
|
1685
|
+
// Reprise d'une présentation existante (depuis le panneau « Présentations en direct ») → prioritaire.
|
|
1686
|
+
if(CFG.resumeSlug){ setTimeout(function(){ resumePresent(CFG.resumeSlug); }, 200); }
|
|
1687
|
+
// Ouverture « Présenter » depuis le menu Documents → démarre tout de suite.
|
|
1688
|
+
else if(CFG.autoPresent){ setTimeout(startPresent, 120); }
|
|
1689
|
+
// Le transfert a réussi côté app → je cesse de piloter localement sans clôturer.
|
|
1690
|
+
// Transfert confirmé : on cesse de piloter ET on retire la présence (untrack) AVANT de laisser l'app fermer
|
|
1691
|
+
// le viewer → pas de présence fantôme. On rend la main à l'app (present-left) une fois nettoyé.
|
|
1692
|
+
Player.bridge.onHostMessage(function(m){ if(m.type==='handover-done'){ stopPilotingLocally(); setTimeout(function(){ Player.bridge.sendToHost({type:'present-left'}); }, 120); } });
|
|
1693
|
+
} else {
|
|
1694
|
+
try{ wireShare(); }catch(e){}
|
|
1695
|
+
}
|
|
1696
|
+
function loadError(m){ var l=document.getElementById('lpct'); if(l){ l.textContent=m; l.className='lerr'; } var b=document.getElementById('lbar'); if(b)b.style.display='none'; }
|
|
1697
|
+
function hideLoader(){ var l=document.getElementById('load'); if(l&&!l.classList.contains('hide')){ l.classList.add('hide'); setTimeout(function(){ if(l.parentNode) l.parentNode.removeChild(l); },450); } }
|
|
1698
|
+
if(!window.pdfjsLib){ loadError("Impossible de charger la visionneuse."); return; }
|
|
1699
|
+
function start(){
|
|
1700
|
+
// Ouverture journalisée, chrono lancé, écouteurs de visibilité / focus / inactivité posés.
|
|
1701
|
+
T.start();
|
|
1702
|
+
document.getElementById('zin').addEventListener('click',function(){ setZoom(zoom+0.2); });
|
|
1703
|
+
document.getElementById('zout').addEventListener('click',function(){ setZoom(zoom-0.2); });
|
|
1704
|
+
render();
|
|
1705
|
+
}
|
|
1706
|
+
function setZoom(z){ zoom=Player.viewer.clampZoom(z); document.getElementById('zlbl').textContent=Math.round(zoom*100)+'%'; if(pdfDoc) build(); }
|
|
1707
|
+
// Vrai worker SAME-ORIGIN via blob (un Worker cross-origin est bloqué par le navigateur → "fake worker").
|
|
1708
|
+
var wsrc=CFG.pdfjs+'/pdf.worker.min.js';
|
|
1709
|
+
try{
|
|
1710
|
+
fetch(wsrc).then(function(r){return r.ok?r.text():null;}).then(function(t){
|
|
1711
|
+
try{ pdfjsLib.GlobalWorkerOptions.workerSrc = t ? URL.createObjectURL(new Blob([t],{type:'application/javascript'})) : wsrc; }catch(e){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; }
|
|
1712
|
+
start();
|
|
1713
|
+
}).catch(function(){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; start(); });
|
|
1714
|
+
}catch(e){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; start(); }
|
|
1715
|
+
// Bord à bord en une-page mobile : chaque millimètre compte, surtout pour un PDF paysage.
|
|
1716
|
+
function baseWidth(){ var pad=(onePage&&window.innerWidth<=820)?0:30; return (scrollEl.clientWidth||900)-pad; }
|
|
1717
|
+
// Hauteur du document OCCULTÉE par la bottom sheet du bot (mobile, état compagnon) : la page une-page se
|
|
1718
|
+
// re-fit dans l'espace VISIBLE au-dessus — le prospect voit la page ET la conversation en même temps.
|
|
1719
|
+
// 0 sur desktop (panneau latéral), sheet réduite, ou état plein (doc en pause derrière le scrim).
|
|
1720
|
+
function isLand(){ return window.matchMedia&&matchMedia('(orientation: landscape) and (max-height: 520px)').matches; } // téléphone tenu en paysage
|
|
1721
|
+
function botOverlap(){ if(window.innerWidth>820)return 0; var p=document.getElementById('botc'); if(!p||p.classList.contains('min')||document.body.classList.contains('botsheet-c'))return 0;
|
|
1722
|
+
try{ var r=p.getBoundingClientRect(), s=scrollEl.getBoundingClientRect(); return Math.max(0,s.bottom-r.top); }catch(e){ return 0; } }
|
|
1723
|
+
// Largeur cible d'une page. En mode « une seule page » on la borne par la HAUTEUR dispo (moins la sheet) →
|
|
1724
|
+
// la page tient entièrement dans le cadre visible. Sinon largeur classique (défilement vertical).
|
|
1725
|
+
// Bande basse du mode barre : hauteur RÉELLE occupée par le bandeau (+ son décalage 26px). Sert au
|
|
1726
|
+
// centrage (padding-bottom) ET au fit (avec 20px de respiration en plus) → marges symétriques ~24px
|
|
1727
|
+
// au-dessus du document et entre le document et le bandeau (avant : collé en haut, tout le vide en bas).
|
|
1728
|
+
function capReserve(){ if(!document.body.classList.contains('deskcap'))return 0;
|
|
1729
|
+
var dc=document.getElementById('dcap'); var bh=(dc&&dc.offsetHeight)||0; return Math.max(112,bh+26); }
|
|
1730
|
+
// En barre, le header est en position:fixed (il RECOUVRE le haut) → on réserve aussi sa hauteur
|
|
1731
|
+
// (padding-top 58px en CSS) pour que la page ne passe jamais dessous : band+54 = 58 haut + 20 bas + marge.
|
|
1732
|
+
function onePageReserve(){ var band=capReserve(); return document.body.classList.contains('botplayer')?(document.body.classList.contains('vsplit')?Math.round(window.innerHeight*0.38)+70:(isLand()?12:260)):(band?band:(document.body.classList.contains('deskaudio')?4:0)); }
|
|
1733
|
+
// La géométrie (bornes, respiration, seuil d'abandon) vit dans player/src/viewer.ts ; ici on ne
|
|
1734
|
+
// fournit que les MESURES, seules choses que le DOM connaisse.
|
|
1735
|
+
function targetWidth(){ return Player.viewer.fitWidth({containerWidth:baseWidth(),containerHeight:scrollEl.clientHeight,zoom:zoom,onePage:onePage,aspect:firstAspect,overlap:botOverlap(),reserve:onePageReserve()}); }
|
|
1736
|
+
// IMAGE — la visionneuse ne savait lire QUE du PDF (pdf.js). Une axonométrie en .jpg
|
|
1737
|
+
// partait donc dans getDocument() et échouait sur « structure invalide ».
|
|
1738
|
+
// Elle devient une page unique : tout le chrome — loader, zoom, plein écran, Partager,
|
|
1739
|
+
// Télécharger, suivi de consultation — est générique et fonctionne tel quel.
|
|
1740
|
+
function renderImage(){
|
|
1741
|
+
var img=new Image();
|
|
1742
|
+
img.onload=function(){
|
|
1743
|
+
numPages=1; window.__n=1; T.setPageCount(1);
|
|
1744
|
+
firstAspect=(img.naturalHeight||1)/(img.naturalWidth||1);
|
|
1745
|
+
var pg=document.getElementById('pg'); if(pg)pg.textContent='Page 1 / 1';
|
|
1746
|
+
imgSrc=CFG.fileUrl;
|
|
1747
|
+
build();
|
|
1748
|
+
try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){}
|
|
1749
|
+
};
|
|
1750
|
+
img.onerror=function(){ loadError("Impossible d'afficher ce document."); };
|
|
1751
|
+
img.src=CFG.fileUrl;
|
|
1752
|
+
}
|
|
1753
|
+
function render(){
|
|
1754
|
+
if(IS_IMG){ renderImage(); return; }
|
|
1755
|
+
var task=pdfjsLib.getDocument(CFG.fileUrl);
|
|
1756
|
+
task.onProgress=function(p){ if(p&&p.total){ var pct=Math.max(8,Math.min(99,Math.round(p.loaded/p.total*100))); var bar=document.getElementById('lbar'); if(bar)bar.classList.remove('idle'); var f=document.getElementById('lbarFill'); if(f)f.style.width=pct+'%'; var l=document.getElementById('lpct'); if(l)l.textContent=pct+' %'; } };
|
|
1757
|
+
task.promise.then(function(pdf){
|
|
1758
|
+
pdfDoc=pdf; numPages=pdf.numPages; window.__n=pdf.numPages; T.setPageCount(pdf.numPages);
|
|
1759
|
+
document.getElementById('pg').textContent='Page 1 / '+pdf.numPages;
|
|
1760
|
+
pdf.getPage(1).then(function(p){ var vp=p.getViewport({scale:1}); firstAspect=vp.height/vp.width; build(); try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){} })
|
|
1761
|
+
.catch(function(){ build(); try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){} });
|
|
1762
|
+
}).catch(function(){ loadError("Impossible d'afficher ce document."); });
|
|
1763
|
+
}
|
|
1764
|
+
function build(){
|
|
1765
|
+
rendered={};
|
|
1766
|
+
if(io) io.disconnect();
|
|
1767
|
+
if(ioCur) ioCur.disconnect();
|
|
1768
|
+
// PRÉ-RENDU : marge de 500px → on rend les pages un peu avant qu'elles arrivent (fluide).
|
|
1769
|
+
io=new IntersectionObserver(function(es){es.forEach(function(e){var n=+e.target.dataset.p; if(e.isIntersecting){renderPage(n,e.target);}});},{root:scrollEl,rootMargin:Player.viewer.PRERENDER_MARGIN,threshold:0.01});
|
|
1770
|
+
// PAGE COURANTE : bande FINE au centre du viewport (marge négative) → cur = la page réellement centrée,
|
|
1771
|
+
// pas polluée par la marge de pré-rendu. Corrige le décalage d'une page présentateur ↔ audience.
|
|
1772
|
+
ioCur=new IntersectionObserver(function(es){es.forEach(function(e){ if(e.isIntersecting){ setCur(+e.target.dataset.p); } });},{root:scrollEl,rootMargin:Player.viewer.CURRENT_PAGE_MARGIN,threshold:0});
|
|
1773
|
+
pagesEl.innerHTML='';
|
|
1774
|
+
var w=Math.round(targetWidth());
|
|
1775
|
+
for(var i=1;i<=numPages;i++){ var d=document.createElement('div'); d.className='page ph'; d.dataset.p=i; d.style.width=w+'px'; d.style.height=Math.round(w*firstAspect)+'px'; d.textContent='Page '+i; pagesEl.appendChild(d); io.observe(d); ioCur.observe(d); }
|
|
1776
|
+
var _band=capReserve(); var _pb=document.body.classList.contains('botplayer')?(document.body.classList.contains('vsplit')?Math.round(window.innerHeight*0.38)+50:(isLand()?0:240)):(botOverlap()+(_band?_band+12:(document.body.classList.contains('deskaudio')?16:0))); pagesEl.style.paddingBottom = onePage ? (_pb+'px') : ''; // centre la page dans l'espace VISIBLE (au-dessus de la sheet mobile / du bandeau desktop / sous le header en audio seul)
|
|
1777
|
+
if(onePage) showPage(cur||1);
|
|
1778
|
+
}
|
|
1779
|
+
// ── Mode « une seule page » : afficher / tourner une page à la fois, sans défilement ──────────────────
|
|
1780
|
+
function syncArrows(){ var st=Player.viewer.arrowState(cur,numPages); var pv=document.getElementById('opPrev'), nx=document.getElementById('opNext'); if(pv)pv.disabled=st.prevDisabled; if(nx)nx.disabled=st.nextDisabled; }
|
|
1781
|
+
function showPage(p){ p=Player.viewer.clampPage(p,numPages); try{ document.body.classList.toggle('pgback',(+p)<(cur||1)); }catch(e){} // sens du glissé (avant/arrière)
|
|
1782
|
+
var els=pagesEl.querySelectorAll('.page'); for(var i=0;i<els.length;i++){ els[i].classList.toggle('cur',(+els[i].dataset.p)===p); } var el=pagesEl.querySelector('.page[data-p="'+p+'"]'); if(el){ renderPage(p,el); var nx=pagesEl.querySelector('.page[data-p="'+(p+1)+'"]'); if(nx)renderPage(p+1,nx); } setCur(p); syncArrows();
|
|
1783
|
+
var pf=document.getElementById('pglineF'); if(pf&&numPages)pf.style.width=Player.viewer.progressPercent(p,numPages)+'%'; } // ligne de progression (mode présentation)
|
|
1784
|
+
function enterOnePage(){ if(onePage)return; onePage=true; document.body.classList.add('onepage'); document.body.classList.add('botlock'); if(pdfDoc){ var c=cur||1; build(); showPage(c); } syncArrows(); }
|
|
1785
|
+
function exitOnePage(){ if(!onePage)return; onePage=false; soloOffered=false; document.body.classList.remove('onepage'); document.body.classList.remove('botlock'); var c=cur||1; if(pdfDoc){ build(); setTimeout(function(){ scrollToPage(c); },30); } syncArrows(); }
|
|
1786
|
+
// Re-fit du document à la largeur réelle (resize fenêtre, ouverture/fermeture du chat ancré, plein écran)
|
|
1787
|
+
// en conservant la page courante. Débouncé pour rester fluide.
|
|
1788
|
+
function scrollToPage(p){ var el=pagesEl.querySelector('.page[data-p="'+p+'"]'); if(el) el.scrollIntoView({block:'start'}); }
|
|
1789
|
+
window.__refit=function(){ if(!pdfDoc)return; var c=cur||1; build(); setTimeout(function(){ scrollToPage(c); },30); };
|
|
1790
|
+
var _rzT; window.addEventListener('resize',function(){ clearTimeout(_rzT); _rzT=setTimeout(function(){ window.__refit(); },160); });
|
|
1791
|
+
// ÉCRAN PARTAGÉ mobile : le fond au-dessus/en-dessous du document prolonge les couleurs de la page
|
|
1792
|
+
// (échantillon des bords haut/bas du canvas) — du header jusqu'à la vidéo, dynamique à chaque page.
|
|
1793
|
+
function vsplitTint(){ if(!document.body.classList.contains('vsplit'))return;
|
|
1794
|
+
var el=document.querySelector('.page[data-p="'+(cur||1)+'"] canvas'); if(!el)return;
|
|
1795
|
+
try{ var cx=el.getContext('2d'); var w=el.width,h=el.height; if(!w||!h)return;
|
|
1796
|
+
function avg(y,rows){ var d=cx.getImageData(0,y,w,rows).data,r=0,g=0,b=0,n=d.length/4;
|
|
1797
|
+
for(var i=0;i<d.length;i+=4){ r+=d[i]; g+=d[i+1]; b+=d[i+2]; }
|
|
1798
|
+
return 'rgb('+Math.round(r/n)+','+Math.round(g/n)+','+Math.round(b/n)+')'; }
|
|
1799
|
+
var top=avg(2,3), bot=avg(Math.max(0,h-5),3);
|
|
1800
|
+
scrollEl.style.background='linear-gradient(180deg,'+top+' 0%,'+top+' 42%,'+bot+' 58%,'+bot+' 100%)';
|
|
1801
|
+
}catch(e){ /* canvas indisponible → fond par défaut */ } }
|
|
1802
|
+
function renderPage(n,el){ if(rendered[n])return; rendered[n]=1;
|
|
1803
|
+
if(IS_IMG){
|
|
1804
|
+
var w=Math.round(targetWidth());
|
|
1805
|
+
var im=document.createElement('img');
|
|
1806
|
+
im.src=imgSrc; im.alt=''; im.style.width=w+'px'; im.style.display='block';
|
|
1807
|
+
el.style.height=''; el.classList.remove('ph'); el.textContent=''; el.style.width=w+'px';
|
|
1808
|
+
el.appendChild(im);
|
|
1809
|
+
im.onload=function(){ hideLoader(); };
|
|
1810
|
+
if(im.complete) hideLoader();
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
pdfDoc.getPage(n).then(function(page){
|
|
1814
|
+
var dpr=window.devicePixelRatio||1;
|
|
1815
|
+
var scale=Math.min(5,targetWidth()/page.getViewport({scale:1}).width);
|
|
1816
|
+
var v=page.getViewport({scale:scale});
|
|
1817
|
+
var c=document.createElement('canvas');
|
|
1818
|
+
c.width=Math.floor(v.width*dpr); c.height=Math.floor(v.height*dpr); // backing store HD → net comme du natif
|
|
1819
|
+
c.style.width=v.width+'px'; c.style.height=v.height+'px';
|
|
1820
|
+
el.style.height=''; el.classList.remove('ph'); el.textContent=''; el.style.width=v.width+'px'; el.appendChild(c); // classList.remove (PAS className=) : ne pas écraser .cur — en mode une-page la page disparaissait une fois rendue (refit après réduction/réouverture du chat)
|
|
1821
|
+
page.render({canvasContext:c.getContext('2d'),viewport:v,transform:dpr!==1?[dpr,0,0,dpr,0,0]:null}).promise.then(function(){ hideLoader(); if(n===cur)setTimeout(vsplitTint,60); });
|
|
1822
|
+
// Couche texte (sélection). En pdf.js v3, les spans utilisent font-size:calc(var(--scale-factor)*Npx)
|
|
1823
|
+
// → SANS --scale-factor, taille nulle = pas de sélection. On le pose sur le conteneur.
|
|
1824
|
+
try{ page.getTextContent().then(function(tc){
|
|
1825
|
+
var tl=document.createElement('div'); tl.className='textLayer';
|
|
1826
|
+
tl.style.width=v.width+'px'; tl.style.height=v.height+'px'; tl.style.setProperty('--scale-factor', scale);
|
|
1827
|
+
el.appendChild(tl);
|
|
1828
|
+
try{ pdfjsLib.renderTextLayer({textContentSource:tc,container:tl,viewport:v}); }
|
|
1829
|
+
catch(e){ try{ pdfjsLib.renderTextLayer({textContent:tc,container:tl,viewport:v}); }catch(e2){} }
|
|
1830
|
+
}); }catch(e){}
|
|
1831
|
+
}); }
|
|
1832
|
+
// ── Assistant IA « présentateur » : chat requête/réponse (bot-start/bot-say) + saut de page piloté ──
|
|
1833
|
+
${botOn && botBrowser ? botBrowser.botViewerJs(ICONS) : ""}
|
|
1834
|
+
})();
|
|
1835
|
+
</script>
|
|
1836
|
+
</body></html>`;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// CSP de la page audience (Présenter) : autorise pdf.js (cdnjs), supabase-js (jsdelivr) et la connexion
|
|
1840
|
+
// Realtime (https + wss vers le projet Supabase). Plus permissive que la visionneuse, limitée à cette page.
|
|
1841
|
+
function sendPresentHtml(res, html, nonce, supaUrl, imgExtra, frameAncestors) {
|
|
1842
|
+
const wss = String(supaUrl || "").replace(/^https:/, "wss:");
|
|
1843
|
+
res.statusCode = 200;
|
|
1844
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1845
|
+
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
1846
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1847
|
+
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
1848
|
+
res.setHeader("Content-Security-Policy", [
|
|
1849
|
+
"default-src 'none'",
|
|
1850
|
+
`script-src 'nonce-${nonce}' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com https://maps.googleapis.com https://maps.gstatic.com`,
|
|
1851
|
+
"worker-src 'self' blob: https://cdnjs.cloudflare.com",
|
|
1852
|
+
`connect-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://nominatim.openstreetmap.org https://*.googleapis.com https://*.gstatic.com ${supaUrl} ${wss}`,
|
|
1853
|
+
`img-src 'self' data: blob: https://*.tile.openstreetmap.org https://unpkg.com https://*.googleapis.com https://*.gstatic.com https://*.ggpht.com https://*.googleusercontent.com ${supaUrl}${imgExtra ? " " + imgExtra : ""}`,
|
|
1854
|
+
"style-src 'unsafe-inline' https://unpkg.com https://fonts.googleapis.com",
|
|
1855
|
+
"font-src https://fonts.gstatic.com data:",
|
|
1856
|
+
"base-uri 'none'",
|
|
1857
|
+
`frame-ancestors ${frameAncestors || "'none'"}`,
|
|
1858
|
+
].join("; "));
|
|
1859
|
+
res.end(html);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// Page AUDIENCE d'une présentation live : affiche UNE page (celle du présentateur), suivie en temps réel
|
|
1863
|
+
// via Supabase Realtime. Navigation verrouillée (page à page). « Terminée » quand le présentateur ferme.
|
|
1864
|
+
function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
|
|
1865
|
+
const title = esc(pres.doc_title || pres.file_name || "Document");
|
|
1866
|
+
const presenter = esc(pres.presenter_name || "");
|
|
1867
|
+
const logo = esc(logoUrl || "");
|
|
1868
|
+
const fileUrl = `/api/doc?present=${encodeURIComponent(pres.slug)}&file=1`;
|
|
1869
|
+
const cfg = JSON.stringify({ fileUrl, docUrl: pres.file_url, pdfjs: PDFJS, slug: pres.slug, page: pres.current_page || 1, active: pres.active !== false, content: pres.content || null, supaUrl, supaKey, title: pres.doc_title || pres.file_name || "Document" });
|
|
1870
|
+
return `<!doctype html><html lang=fr><head><meta charset=utf-8>
|
|
1871
|
+
<meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=3">
|
|
1872
|
+
<meta name=robots content="noindex,nofollow">
|
|
1873
|
+
<link rel=preconnect href="https://cdnjs.cloudflare.com" crossorigin>
|
|
1874
|
+
<title>${esc(PLAYER.branding.title(pres.doc_title || pres.file_name || "Document", "Présentation"))}</title>
|
|
1875
|
+
<style>
|
|
1876
|
+
:root{--bg:#23211e;--bar:#1a1916}
|
|
1877
|
+
*{box-sizing:border-box}
|
|
1878
|
+
html,body{margin:0;height:100%}
|
|
1879
|
+
body{background:var(--bg);font:14px/1.5 -apple-system,system-ui,Segoe UI,Roboto,sans-serif;color:#eee;display:flex;flex-direction:column;overflow:hidden}
|
|
1880
|
+
.bar{display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--bar);border-bottom:1px solid #0004;flex:none}
|
|
1881
|
+
.bar b.t{font-size:14px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:42vw}
|
|
1882
|
+
.live{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:800;letter-spacing:.06em;color:#ff5d5d;text-transform:uppercase}
|
|
1883
|
+
.live i{width:8px;height:8px;border-radius:50%;background:#ff3b3b;animation:blink 1.4s ease-in-out infinite}
|
|
1884
|
+
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
|
|
1885
|
+
.sp{flex:1}
|
|
1886
|
+
.pg{font-size:12.5px;color:#cfcbc4;white-space:nowrap}
|
|
1887
|
+
.pg b{color:#fff}
|
|
1888
|
+
.by{font-size:12px;color:#a6a199;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:24vw}
|
|
1889
|
+
.stage{flex:1;position:relative;display:flex;align-items:center;justify-content:center;padding:20px;overflow:hidden}
|
|
1890
|
+
#page{background:#fff;box-shadow:0 10px 40px #0008;border-radius:3px;max-width:100%;max-height:100%}
|
|
1891
|
+
#page canvas{display:block;border-radius:3px;max-width:100%;max-height:100%}
|
|
1892
|
+
#load{position:absolute;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:4;transition:opacity .4s}
|
|
1893
|
+
#load.hide{opacity:0;pointer-events:none}
|
|
1894
|
+
.lbox{display:flex;flex-direction:column;align-items:center;gap:18px}
|
|
1895
|
+
.lbox img{height:34px;opacity:.92;animation:lp 1.6s ease-in-out infinite}
|
|
1896
|
+
.lword{font-weight:800;font-size:20px;color:#fff;animation:lp 1.6s ease-in-out infinite}
|
|
1897
|
+
@keyframes lp{0%,100%{opacity:.5}50%{opacity:1}}
|
|
1898
|
+
.lsub{font-size:11.5px;color:#8a857c;letter-spacing:.03em}
|
|
1899
|
+
.ended{position:absolute;inset:0;background:rgba(20,18,15,.93);display:none;align-items:center;justify-content:center;z-index:6;text-align:center;padding:24px}
|
|
1900
|
+
.ended .ettl{font-size:20px;font-weight:800;margin-bottom:8px}
|
|
1901
|
+
.ended .esub{font-size:13.5px;color:#bbb}
|
|
1902
|
+
.ended .elogo{height:30px;margin-bottom:22px;opacity:.9}
|
|
1903
|
+
.takeover{position:fixed;top:58px;left:50%;transform:translateX(-50%);z-index:70;display:none;align-items:center;gap:8px;border:0;background:#e5384d;color:#fff;font:inherit;font-size:13.5px;font-weight:700;padding:11px 20px;border-radius:999px;cursor:pointer;box-shadow:0 10px 34px rgba(0,0,0,.35);animation:tkPulse 2s infinite}
|
|
1904
|
+
@keyframes tkPulse{0%,100%{box-shadow:0 10px 34px rgba(229,56,77,.35)}50%{box-shadow:0 10px 34px rgba(229,56,77,.7)}}
|
|
1905
|
+
.takeover:hover{background:#cf2d40}
|
|
1906
|
+
.brand{position:fixed;right:13px;bottom:9px;font-size:10px;color:#fff;opacity:.4;pointer-events:none;z-index:3}
|
|
1907
|
+
.ic{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:1px solid #fff3;background:transparent;color:#fff;border-radius:8px;cursor:pointer;padding:0}
|
|
1908
|
+
.ic svg{width:16px;height:16px}.ic:hover{background:#fff2}
|
|
1909
|
+
@media (max-width:700px){ .bar{gap:9px;padding:9px 12px} .by{display:none} .live{font-size:0;gap:0} .live i{width:9px;height:9px} .bar b.t{max-width:34vw} }
|
|
1910
|
+
@media (max-width:430px){ .bar b.t{max-width:40vw} .pg{font-size:11.5px} }
|
|
1911
|
+
${LEGAL_CSS}
|
|
1912
|
+
${LIVE_CSS}
|
|
1913
|
+
${MAP_CSS}
|
|
1914
|
+
</style></head>
|
|
1915
|
+
<body>
|
|
1916
|
+
<div class=bar>
|
|
1917
|
+
<b class=t id=ptitle>${title}</b>
|
|
1918
|
+
<span class=live><i></i> En direct</span>
|
|
1919
|
+
<span class=sp></span>
|
|
1920
|
+
<span class=pg>Page <b id=cur>${pres.current_page || 1}</b> / <span id=tot>—</span></span>
|
|
1921
|
+
${presenter ? `<span class=by>par ${presenter}</span>` : ""}
|
|
1922
|
+
${LIVE_BAR}
|
|
1923
|
+
</div>
|
|
1924
|
+
<button class=takeover id=takeOver style="display:none">🎤 Vous êtes le présentateur — Reprendre la main</button>
|
|
1925
|
+
<div class=lrow>
|
|
1926
|
+
<div class=lmain>
|
|
1927
|
+
<div class=stage id=stage>
|
|
1928
|
+
<div id=page></div>
|
|
1929
|
+
${MAP_MARKUP}
|
|
1930
|
+
<div id=load><div class=lbox>${logo ? `<img src="${logo}" alt="${esc(PLAYER.branding.loaderName || "Chargement")}">` : (PLAYER.branding.loaderName ? `<div class=lword>${esc(PLAYER.branding.loaderName)}</div>` : "")}<div class=lsub>Connexion à la présentation…</div></div></div>
|
|
1931
|
+
<div class=ended id=ended><div>${logo ? `<img class=elogo src="${logo}" alt="">` : ""}<div class=ettl>Présentation terminée</div><div class=esub>Le présentateur a mis fin à la session.</div></div></div>
|
|
1932
|
+
</div>
|
|
1933
|
+
</div>
|
|
1934
|
+
${LIVE_PANEL}
|
|
1935
|
+
</div>
|
|
1936
|
+
${PLAYER.branding.poweredBy ? `<div class=brand>Propulsé par ${esc(PLAYER.branding.poweredBy)}</div>` : ""}
|
|
1937
|
+
${legalFooter({ tracked: true })}
|
|
1938
|
+
<script nonce="${nonce}">${PLAYER_BROWSER_JS}</script>
|
|
1939
|
+
<script nonce="${nonce}" src="${PDFJS}/pdf.min.js"></script>
|
|
1940
|
+
<script nonce="${nonce}" src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/dist/umd/supabase.js"></script>
|
|
1941
|
+
<script nonce="${nonce}">
|
|
1942
|
+
(function(){
|
|
1943
|
+
var CFG=${cfg.replace(/</g, "\\u003c")};
|
|
1944
|
+
var PDF=null, total=0, cur=CFG.page||1, ready=false;
|
|
1945
|
+
var stage=document.getElementById('stage'), pageEl=document.getElementById('page');
|
|
1946
|
+
function hideLoader(){ var l=document.getElementById('load'); if(l){ l.classList.add('hide'); setTimeout(function(){ if(l.parentNode)l.parentNode.removeChild(l); },450);} }
|
|
1947
|
+
function ended(){ document.getElementById('ended').style.display='flex'; }
|
|
1948
|
+
function show(n){
|
|
1949
|
+
if(!PDF) return;
|
|
1950
|
+
n=Math.max(1,Math.min(total||1, n||1)); cur=n;
|
|
1951
|
+
var c=document.getElementById('cur'); if(c)c.textContent=n;
|
|
1952
|
+
PDF.getPage(n).then(function(page){
|
|
1953
|
+
var availW=Math.max(120, stage.clientWidth-40), availH=Math.max(120, stage.clientHeight-40);
|
|
1954
|
+
var v1=page.getViewport({scale:1});
|
|
1955
|
+
var scale=Math.min(availW/v1.width, availH/v1.height); // fit entier → une page à l'écran
|
|
1956
|
+
var dpr=window.devicePixelRatio||1, vp=page.getViewport({scale:scale});
|
|
1957
|
+
var cv=document.createElement('canvas'); cv.width=Math.floor(vp.width*dpr); cv.height=Math.floor(vp.height*dpr);
|
|
1958
|
+
cv.style.width=vp.width+'px'; cv.style.height=vp.height+'px';
|
|
1959
|
+
page.render({canvasContext:cv.getContext('2d'),viewport:vp,transform:dpr!==1?[dpr,0,0,dpr,0,0]:null});
|
|
1960
|
+
pageEl.innerHTML=''; pageEl.appendChild(cv);
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
var wsrc=CFG.pdfjs+'/pdf.worker.min.js';
|
|
1964
|
+
function boot(){
|
|
1965
|
+
fetch(wsrc).then(function(r){return r.ok?r.text():null;}).then(function(t){
|
|
1966
|
+
try{ pdfjsLib.GlobalWorkerOptions.workerSrc=t?URL.createObjectURL(new Blob([t],{type:'application/javascript'})):wsrc; }catch(e){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; }
|
|
1967
|
+
load();
|
|
1968
|
+
}).catch(function(){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; load(); });
|
|
1969
|
+
}
|
|
1970
|
+
function load(){
|
|
1971
|
+
pdfjsLib.getDocument(CFG.fileUrl).promise.then(function(pdf){
|
|
1972
|
+
PDF=pdf; total=pdf.numPages; ready=true;
|
|
1973
|
+
var tot=document.getElementById('tot'); if(tot)tot.textContent=total;
|
|
1974
|
+
show(cur); hideLoader();
|
|
1975
|
+
if(!CFG.active) ended();
|
|
1976
|
+
}).catch(function(){ var l=document.getElementById('load'); if(l)l.querySelector('.lsub').textContent="Document indisponible."; });
|
|
1977
|
+
}
|
|
1978
|
+
// Le présentateur a changé de document (même session) → recharger le PDF servi par le proxy (URL identique →
|
|
1979
|
+
// cache-buster obligatoire), remettre à la page 1, mettre à jour le titre.
|
|
1980
|
+
function switchDoc(row){
|
|
1981
|
+
var t=document.getElementById('ptitle'); if(t) t.textContent=row.doc_title||row.file_name||'Document';
|
|
1982
|
+
var pg=document.getElementById('page'); if(pg) pg.innerHTML='';
|
|
1983
|
+
cur=1; total=0; ready=false;
|
|
1984
|
+
var tot=document.getElementById('tot'); if(tot)tot.textContent='—';
|
|
1985
|
+
var cu=document.getElementById('cur'); if(cu)cu.textContent='1';
|
|
1986
|
+
CFG.fileUrl='/api/doc?present='+encodeURIComponent(CFG.slug)+'&file=1&v='+encodeURIComponent(row.updated_at||String(row.current_page||1));
|
|
1987
|
+
load();
|
|
1988
|
+
}
|
|
1989
|
+
if(window.pdfjsLib) boot(); else { var s=document.querySelector('script[src*="pdf.min.js"]'); if(s)s.addEventListener('load',boot); }
|
|
1990
|
+
window.__refit=function(){ if(ready) show(cur); };
|
|
1991
|
+
var _rzA; window.addEventListener('resize',function(){ clearTimeout(_rzA); _rzA=setTimeout(function(){ if(ready) show(cur); },140); });
|
|
1992
|
+
// Ce que l'audience fait d'un état reçu — player/src/presentation-state.ts, testé.
|
|
1993
|
+
// TROIS sources l'alimentent : la table (ci-dessous), une relecture d'état, et bientôt une
|
|
1994
|
+
// diffusion temps réel. La règle d'ordre (terminée > carte > changement de doc > page) et la
|
|
1995
|
+
// re-validation du contenu vivent dans le module, pas ici.
|
|
1996
|
+
var _etatVu='';
|
|
1997
|
+
function appliquerEtat(row){
|
|
1998
|
+
if(!row) return;
|
|
1999
|
+
// La table et la diffusion portent la même vérité : sans cette garde, chaque changement de
|
|
2000
|
+
// page serait rendu deux fois (scintillement), et chaque carte ré-ouverte inutilement.
|
|
2001
|
+
var sig=''; try{ sig=JSON.stringify([row.active,row.current_page,row.file_url,row.content]); }catch(e){ sig=String(Math.random()); }
|
|
2002
|
+
if(sig===_etatVu) return;
|
|
2003
|
+
_etatVu=sig;
|
|
2004
|
+
var actions=Player.presentationState.presentationTransition(row,{docUrl:CFG.docUrl});
|
|
2005
|
+
for(var i=0;i<actions.length;i++){ var a=actions[i];
|
|
2006
|
+
if(a.kind==='ended'){ ended(); return; }
|
|
2007
|
+
if(a.kind==='show-map'){ if(window.Map3DD){ if(a.content.kind==='streetview') Map3DD.enterSV(a.content,false); else Map3DD.enter(a.content,false); } return; }
|
|
2008
|
+
if(a.kind==='leave-map'){ if(window.Map3DD) Map3DD.exit(); }
|
|
2009
|
+
if(a.kind==='switch-doc'){ CFG.docUrl=a.url; switchDoc(row); return; }
|
|
2010
|
+
if(a.kind==='show-page'){ show(a.page); }
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
// RELECTURE D'ÉTAT — la porte qui permettra de se passer de la lecture anonyme des tables.
|
|
2014
|
+
// Au retour d'un onglet caché ou d'une coupure réseau, on redemande l'état au serveur plutôt
|
|
2015
|
+
// que d'espérer avoir reçu tous les événements pendant l'absence.
|
|
2016
|
+
function relireEtat(){
|
|
2017
|
+
try{ fetch('/api/doc?present='+encodeURIComponent(CFG.slug)+'&state=1',{cache:'no-store'})
|
|
2018
|
+
.then(function(r){return r.json();}).then(function(d){ if(d&&d.ok) appliquerEtat(d.state); }).catch(function(){}); }catch(e){}
|
|
2019
|
+
}
|
|
2020
|
+
document.addEventListener('visibilitychange',function(){ if(document.visibilityState==='visible') relireEtat(); });
|
|
2021
|
+
window.addEventListener('online', relireEtat);
|
|
2022
|
+
|
|
2023
|
+
// Realtime : suivre la page courante + la fin de session (UPDATE de notre ligne).
|
|
2024
|
+
try{
|
|
2025
|
+
if(window.supabase && CFG.supaUrl && CFG.supaKey){
|
|
2026
|
+
var sb=window.supabase.createClient(CFG.supaUrl, CFG.supaKey, {realtime:{params:{eventsPerSecond:5}}});
|
|
2027
|
+
sb.channel('present-'+CFG.slug)
|
|
2028
|
+
.on('postgres_changes',{event:'UPDATE',schema:'public',table:'doc_presentations',filter:'slug=eq.'+CFG.slug},function(payload){
|
|
2029
|
+
appliquerEtat(payload && payload.new);
|
|
2030
|
+
}).subscribe();
|
|
2031
|
+
}
|
|
2032
|
+
}catch(e){}
|
|
2033
|
+
// Si on rejoint alors que le présentateur est DÉJÀ sur une carte / Street View → l'afficher dès que Map3DD est prêt.
|
|
2034
|
+
if(CFG.content && (CFG.content.kind==='map'||CFG.content.kind==='streetview')){ var _mi=setInterval(function(){ if(window.Map3DD){ clearInterval(_mi); if(CFG.content.kind==='streetview')Map3DD.enterSV(CFG.content,false); else Map3DD.enter(CFG.content,false); } },100); setTimeout(function(){ clearInterval(_mi); },8000); }
|
|
2035
|
+
})();
|
|
2036
|
+
</script>
|
|
2037
|
+
<script nonce="${nonce}">
|
|
2038
|
+
var LIVECFG={supaUrl:${JSON.stringify(supaUrl || "")},supaKey:${JSON.stringify(supaKey || "")}};var GMAPS_KEY=${JSON.stringify(process.env.GOOGLE_MAPS_API_KEY || "")};
|
|
2039
|
+
${LIVE_JS}
|
|
2040
|
+
${MAP_JS}
|
|
2041
|
+
(function(){
|
|
2042
|
+
var slug=${JSON.stringify(pres.slug)};
|
|
2043
|
+
// Carte live : suivre en direct les mouvements du présentateur (broadcast).
|
|
2044
|
+
try{ if(window.Live&&window.Map3DD) Live.onMap(function(p){ Map3DD.apply(p); }); }catch(e){}
|
|
2045
|
+
// L'état arrive maintenant par DEUX voies : la table (historique) et la diffusion du
|
|
2046
|
+
// présentateur (nouvelle). Les deux passent par le même filtre, qui ignore un état déjà
|
|
2047
|
+
// appliqué — recevoir deux fois la même chose ne doit pas re-rendre la page.
|
|
2048
|
+
try{ if(window.Live) Live.onState(appliquerEtat); }catch(e){}
|
|
2049
|
+
// « Reprendre la main » : si le membre connecté (même origine) devient propriétaire de CETTE présentation
|
|
2050
|
+
// (après un transfert), on affiche un bouton pour ouvrir la visionneuse en pilotage — sinon on ne peut pas
|
|
2051
|
+
// piloter depuis la page audience.
|
|
2052
|
+
function appTok(){ try{ var raw=localStorage.getItem('3dd-supabase-auth'); if(!raw)return''; var s=JSON.parse(raw); return (s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||''; }catch(e){return'';} }
|
|
2053
|
+
function checkOwner(){ var tk=appTok(); if(!tk)return; fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tk},body:JSON.stringify({action:'present-list'})}).then(function(r){return r.json();}).then(function(d){ var mine=false; if(d&&d.presentations){ for(var i=0;i<d.presentations.length;i++){ if(d.presentations[i].slug===slug&&d.presentations[i].mine){mine=true;break;} } } var b=document.getElementById('takeOver'); if(b)b.style.display=mine?'inline-flex':'none'; }).catch(function(){}); }
|
|
2054
|
+
function startOwnerWatch(){ var b=document.getElementById('takeOver'); if(b&&!b._w){b._w=1;b.addEventListener('click',function(){ location.href='/app/documents?resume='+encodeURIComponent(slug); });} checkOwner(); setInterval(checkOwner,12000); }
|
|
2055
|
+
var me=Live.detectMember();
|
|
2056
|
+
if(me){ Live.connect(slug, me); startOwnerWatch(); return; }
|
|
2057
|
+
var saved=null; try{ saved=JSON.parse(localStorage.getItem('3dd-present-me')||'null'); }catch(e){}
|
|
2058
|
+
if(saved&&saved.name){ Live.connect(slug, saved); return; }
|
|
2059
|
+
// Externe : on demande le nom pour participer.
|
|
2060
|
+
var o=document.createElement('div'); o.className='join';
|
|
2061
|
+
o.innerHTML='<div class=join-card><h4>Rejoindre la présentation</h4><p>Votre nom pour participer à la discussion.</p><input id=jName placeholder="Votre nom" maxlength=60 autocomplete=name><input id=jMail placeholder="Email (facultatif)" maxlength=120 autocomplete=email><button id=jGo>Rejoindre</button></div>';
|
|
2062
|
+
document.body.appendChild(o);
|
|
2063
|
+
var n=o.querySelector('#jName'); try{ n.focus(); }catch(e){}
|
|
2064
|
+
function go(){ var name=(n.value||'').trim()||'Invité'; var email=(o.querySelector('#jMail').value||'').trim(); var me2={name:name,email:email,avatar:'',member:false,role:'viewer'}; try{ localStorage.setItem('3dd-present-me',JSON.stringify(me2)); }catch(e){} o.parentNode&&o.parentNode.removeChild(o); Live.connect(slug, me2); }
|
|
2065
|
+
o.querySelector('#jGo').addEventListener('click',go);
|
|
2066
|
+
n.addEventListener('keydown',function(e){ if(e.key==='Enter') go(); });
|
|
2067
|
+
})();
|
|
2068
|
+
</script>
|
|
2069
|
+
</body></html>`;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
async function readJsonBody(req) {
|
|
2073
|
+
if (req.body && typeof req.body === "object") return req.body;
|
|
2074
|
+
if (typeof req.body === "string") { try { return JSON.parse(req.body); } catch { return {}; } }
|
|
2075
|
+
return await new Promise((resolve) => {
|
|
2076
|
+
let data = ""; req.on("data", (c) => { data += c; if (data.length > 1e5) req.destroy(); });
|
|
2077
|
+
req.on("end", () => { try { resolve(JSON.parse(data || "{}")); } catch { resolve({}); } });
|
|
2078
|
+
req.on("error", () => resolve({}));
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
async function handler(req, res) {
|
|
2083
|
+
try {
|
|
2084
|
+
const q = req.query || {};
|
|
2085
|
+
const slug = String(q.slug || "").trim();
|
|
2086
|
+
|
|
2087
|
+
if (req.method === "POST") {
|
|
2088
|
+
const body = await readJsonBody(req);
|
|
2089
|
+
// ── Connexion VISITEUR (soft wall) : demande d'un code par email, puis vérification. ──
|
|
2090
|
+
// Émet un jeton signé posé en cookie qui débloque les contenus gatés (require_auth).
|
|
2091
|
+
if (body.action === "visitor-request" || body.action === "visitor-verify" || body.action === "visitor-google") {
|
|
2092
|
+
const jv = (status, obj, cookie) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); if (cookie) res.setHeader("Set-Cookie", cookie); res.end(JSON.stringify(obj)); };
|
|
2093
|
+
const V = PLAYER.plugins.visitors;
|
|
2094
|
+
if (!V) return jv(404, { ok: false, error: "disabled" });
|
|
2095
|
+
const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || "ip";
|
|
2096
|
+
if (body.action === "visitor-request") {
|
|
2097
|
+
if (!(await PLAYER.limits.allow(`vcode:${ip}`, 20, 3600))) return jv(429, { ok: false, error: "rate" });
|
|
2098
|
+
const sh = await getShareBySlug(String(body.slug || ""));
|
|
2099
|
+
return jv(200, await V.requestCode(body.email, { title: sh && sh.doc_title }));
|
|
2100
|
+
}
|
|
2101
|
+
const recordUnlock = async (visitor, method) => {
|
|
2102
|
+
try {
|
|
2103
|
+
if (!body.slug || !visitor || !visitor.email) return;
|
|
2104
|
+
await PLAYER.db.request("xp_visitor_unlocks", { method: "POST", headers: { Prefer: "return=minimal" }, body: [{ doc_slug: String(body.slug), email: visitor.email, name: visitor.name || null, method }] });
|
|
2105
|
+
} catch { /* best-effort */ }
|
|
2106
|
+
};
|
|
2107
|
+
if (body.action === "visitor-google") {
|
|
2108
|
+
const r = await V.verifyGoogle(body.credential);
|
|
2109
|
+
if (r.ok) await recordUnlock(r.visitor, "google");
|
|
2110
|
+
return r.ok ? jv(200, { ok: true }, r.setCookie) : jv(400, r);
|
|
2111
|
+
}
|
|
2112
|
+
const r = await V.verifyCode(body.email, body.code, body.name);
|
|
2113
|
+
if (r.ok) await recordUnlock(r.visitor, "email");
|
|
2114
|
+
return r.ok ? jv(200, { ok: true }, r.setCookie) : jv(400, r);
|
|
2115
|
+
}
|
|
2116
|
+
// Mode « Présenter » : démarrage / changement de page / fin. start = public (URL Storage validée) ;
|
|
2117
|
+
// page & end exigent le control_token (secret présentateur). L'audience (slug seul) ne peut pas piloter.
|
|
2118
|
+
if (body.action === "present-start" || body.action === "present-page" || body.action === "present-end" || body.action === "present-touch") {
|
|
2119
|
+
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2120
|
+
try {
|
|
2121
|
+
if (body.action === "present-start") {
|
|
2122
|
+
if (!isAllowedStorageUrl(String(body.fileUrl || ""))) return jp(400, { ok: false, error: "url" });
|
|
2123
|
+
const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
|
|
2124
|
+
const allowed = await PLAYER.limits.allow(`pstart:${ip}`, 60, 3600);
|
|
2125
|
+
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2126
|
+
// On rattache la présentation au membre (JWT) → reprise / liste / transfert. Best-effort : sans
|
|
2127
|
+
// session valide la présentation démarre quand même mais ne sera pas reprenable.
|
|
2128
|
+
let owner = null;
|
|
2129
|
+
const u = await PLAYER.identity.verifyToken(req.headers.authorization);
|
|
2130
|
+
if (u && u.email) { const m = u.user_metadata || {}; owner = { id: u.id, email: u.email, name: body.presenterName || m.name || u.email || "", avatar: body.presenterAvatar || m.avatarUrl || "" }; }
|
|
2131
|
+
const out = await createPresentation({ docId: body.docId, fileUrl: body.fileUrl, fileName: body.fileName, docTitle: body.docTitle, presenterName: body.presenterName, owner });
|
|
2132
|
+
return jp(200, { ok: true, slug: out.slug, control: out.control });
|
|
2133
|
+
}
|
|
2134
|
+
const r = body.action === "present-page"
|
|
2135
|
+
? await setPage(String(body.slug || ""), String(body.control || ""), body.page)
|
|
2136
|
+
: body.action === "present-touch"
|
|
2137
|
+
? await touchPresentation(String(body.slug || ""), String(body.control || ""))
|
|
2138
|
+
: await endPresentation(String(body.slug || ""), String(body.control || ""));
|
|
2139
|
+
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2140
|
+
} catch { return jp(500, { ok: false }); }
|
|
2141
|
+
}
|
|
2142
|
+
// Assistant IA « présentateur » (bot) sur un lien tracé bot_enabled. PUBLIC (prospect anonyme) → rate-limit IP.
|
|
2143
|
+
// Synthèse vocale (ElevenLabs) : Léa lit ses messages. PUBLIC (audience anonyme), mais gated : la clé
|
|
2144
|
+
// reste côté serveur, le slug doit être un lien-bot valide, et l'audio est mis en CACHE dans le bucket
|
|
2145
|
+
// « tts-cache » (nom = hash voix+modèle+texte) → un message identique n'est synthétisé qu'une fois.
|
|
2146
|
+
if (body.action === "bot-tts") {
|
|
2147
|
+
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2148
|
+
if (!docbot) return jp(404, { ok: false, error: "disabled" });
|
|
2149
|
+
try {
|
|
2150
|
+
const apiKey = process.env.ELEVENLABS_API_KEY;
|
|
2151
|
+
if (!apiKey) return jp(200, { ok: false, disabled: true });
|
|
2152
|
+
const share = await getShareBySlug(String(body.slug || ""));
|
|
2153
|
+
if (!share || !share.bot_enabled) return jp(404, { ok: false, error: "bot" });
|
|
2154
|
+
const text = String(body.text || "").replace(/\s+/g, " ").trim().slice(0, 700);
|
|
2155
|
+
if (!text) return jp(400, { ok: false, error: "empty" });
|
|
2156
|
+
const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
|
|
2157
|
+
if (!(await PLAYER.limits.allow(`doctts:${ip}`, 400, 3600))) return jp(429, { ok: false, error: "rate" });
|
|
2158
|
+
const defaultVoiceId = process.env.ELEVENLABS_VOICE_ID || "21m00Tcm4TlvDq8ikWAM";
|
|
2159
|
+
let voiceId = defaultVoiceId;
|
|
2160
|
+
// Voix PAR AGENT : le profil du lien peut porter sa propre voix ElevenLabs (behavior.voice.id).
|
|
2161
|
+
// Le cache est déjà haché par voix → changer la voix d'un agent régénère proprement ses extraits.
|
|
2162
|
+
let voiceOwner = "", voiceName = "", pron = null;
|
|
2163
|
+
try { const bp = await docbot.getProfile(share.bot_profile_id); const bv = bp && bp.behavior && bp.behavior.voice; if (bv && bv.id) { voiceId = String(bv.id); voiceOwner = String(bv.owner || ""); voiceName = String(bv.name || ""); } pron = docbot.pronFix(bp); } catch { /* voix par défaut, sans prononciation */ }
|
|
2164
|
+
// DIRE ≠ MONTRER : la prononciation (behavior.voice.pron) s'applique ICI, côté serveur — le client
|
|
2165
|
+
// envoie et affiche l'ORTHOGRAPHE, la synthèse (et son cache) travaille sur la version phonétique.
|
|
2166
|
+
// `spoken` est renvoyé quand il diffère → le viewer aligne le karaoké dessus (mapping mot à mot).
|
|
2167
|
+
const spoken = (() => { if (!pron) return text; try { const s = pron(text).replace(/\s+/g, " ").trim().slice(0, 700); return s || text; } catch { return text; } })();
|
|
2168
|
+
const modelId = process.env.ELEVENLABS_MODEL || "eleven_multilingual_v2";
|
|
2169
|
+
const base = process.env.SUPABASE_URL || "";
|
|
2170
|
+
// « v2 » = version du format de cache : les extraits v1 (sans alignement timestamps) sont ignorés
|
|
2171
|
+
// d'office et tout se régénère AVEC l'horodatage par caractère (karaoké exact). Anciens fichiers = poids mort minime.
|
|
2172
|
+
const keyFor = (vid) => crypto.createHash("sha256").update(vid + "|" + modelId + "|v2|" + spoken).digest("hex");
|
|
2173
|
+
let hash = keyFor(voiceId);
|
|
2174
|
+
let objPath = hash + ".mp3";
|
|
2175
|
+
let pub = base + "/storage/v1/object/public/tts-cache/" + objPath;
|
|
2176
|
+
let pubAlign = base + "/storage/v1/object/public/tts-cache/" + hash + ".json";
|
|
2177
|
+
// Cache hit ? On sert directement l'URL CDN (coût ElevenLabs = 0). align : les anciens extraits
|
|
2178
|
+
// n'ont pas de JSON (404) → le client retombe sur la synchro estimée, rien ne casse.
|
|
2179
|
+
try { const head = await fetch(pub, { method: "HEAD" }); if (head.ok) return jp(200, { ok: true, url: pub, align: pubAlign, cached: true, spoken: spoken !== text ? spoken : undefined }); } catch { /* miss */ }
|
|
2180
|
+
// WITH-TIMESTAMPS : audio + horodatage PAR CARACTÈRE → surlignage karaoké EXACT côté client.
|
|
2181
|
+
const synth = (vid) => fetch("https://api.elevenlabs.io/v1/text-to-speech/" + encodeURIComponent(vid) + "/with-timestamps", {
|
|
2182
|
+
method: "POST",
|
|
2183
|
+
headers: { "xi-api-key": apiKey, "Content-Type": "application/json", accept: "application/json" },
|
|
2184
|
+
body: JSON.stringify({ text: spoken, model_id: modelId, voice_settings: { stability: 0.5, similarity_boost: 0.75, style: 0, use_speaker_boost: true } }),
|
|
2185
|
+
});
|
|
2186
|
+
let gen = await synth(voiceId);
|
|
2187
|
+
// Voix de la BIBLIOTHÈQUE pas encore dans le compte → ajout automatique puis nouvel essai ;
|
|
2188
|
+
// si l'ajout échoue (quota de slots), on REPLIE sur la voix par défaut : jamais de présentation muette.
|
|
2189
|
+
if (!gen.ok && voiceOwner && /^[A-Za-z0-9_-]{8,80}$/.test(voiceOwner)) {
|
|
2190
|
+
try {
|
|
2191
|
+
await fetch("https://api.elevenlabs.io/v1/voices/add/" + encodeURIComponent(voiceOwner) + "/" + encodeURIComponent(voiceId), {
|
|
2192
|
+
method: "POST", headers: { "xi-api-key": apiKey, "Content-Type": "application/json" },
|
|
2193
|
+
body: JSON.stringify({ new_name: (voiceName || `Voix ${PLAYER.branding.name || "player"}`).slice(0, 80) }),
|
|
2194
|
+
});
|
|
2195
|
+
} catch { /* repli défaut ci-dessous */ }
|
|
2196
|
+
gen = await synth(voiceId);
|
|
2197
|
+
}
|
|
2198
|
+
if (!gen.ok && voiceId !== defaultVoiceId) {
|
|
2199
|
+
voiceId = defaultVoiceId; gen = await synth(voiceId);
|
|
2200
|
+
// Le repli se met en cache SOUS SA PROPRE clé (voix par défaut) — souvent déjà présente.
|
|
2201
|
+
hash = keyFor(voiceId); objPath = hash + ".mp3";
|
|
2202
|
+
pub = base + "/storage/v1/object/public/tts-cache/" + objPath;
|
|
2203
|
+
pubAlign = base + "/storage/v1/object/public/tts-cache/" + hash + ".json";
|
|
2204
|
+
}
|
|
2205
|
+
if (!gen.ok) { try { await PLAYER.errors.capture(new Error("elevenlabs " + gen.status), { where: "bot-tts" }); } catch { /* noop */ } return jp(200, { ok: false }); }
|
|
2206
|
+
const data = await gen.json().catch(() => null);
|
|
2207
|
+
const buf = data && data.audio_base64 ? Buffer.from(data.audio_base64, "base64") : Buffer.alloc(0);
|
|
2208
|
+
if (!buf.length) return jp(200, { ok: false });
|
|
2209
|
+
const up = await PLAYER.storage.put("tts-cache", objPath, buf, "audio/mpeg");
|
|
2210
|
+
// Surveillance du réservoir ElevenLabs (throttlée 1×/h) — cf. _provider-quotas.js.
|
|
2211
|
+
try { await PLAYER.plugins.providerQuotas?.tick("elevenlabs"); } catch { /* jamais bloquant */ }
|
|
2212
|
+
if (!up) return jp(200, { ok: false });
|
|
2213
|
+
// Alignement compact : instants de DÉBUT par caractère (ms) — mêmes index que le texte envoyé.
|
|
2214
|
+
let hasAlign = false;
|
|
2215
|
+
try {
|
|
2216
|
+
const al = data.alignment || data.normalized_alignment;
|
|
2217
|
+
if (al && Array.isArray(al.character_start_times_seconds)) {
|
|
2218
|
+
const tms = al.character_start_times_seconds.map((x) => Math.round(Number(x) * 1000));
|
|
2219
|
+
hasAlign = await PLAYER.storage.put("tts-cache", hash + ".json", Buffer.from(JSON.stringify({ t: tms })), "application/json");
|
|
2220
|
+
}
|
|
2221
|
+
} catch { /* sans alignement → synchro estimée côté client */ }
|
|
2222
|
+
return jp(200, { ok: true, url: pub, align: hasAlign ? pubAlign : null, spoken: spoken !== text ? spoken : undefined });
|
|
2223
|
+
} catch { return jp(500, { ok: false }); }
|
|
2224
|
+
}
|
|
2225
|
+
if (body.action === "bot-start" || body.action === "bot-say" || body.action === "bot-history" || body.action === "bot-nudge" || body.action === "bot-book" || body.action === "bot-contact" || body.action === "bot-rate" || body.action === "bot-script") {
|
|
2226
|
+
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2227
|
+
if (!docbot) return jp(404, { ok: false, error: "disabled" });
|
|
2228
|
+
try {
|
|
2229
|
+
const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
|
|
2230
|
+
const allowed = await PLAYER.limits.allow(`docbot:${ip}`, 120, 3600);
|
|
2231
|
+
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2232
|
+
const share = await getShareBySlug(String(body.slug || ""));
|
|
2233
|
+
if (!share || !share.bot_enabled) return jp(404, { ok: false, error: "bot" });
|
|
2234
|
+
const pages = Math.max(0, Math.min(500, Number(body.pages) || 0));
|
|
2235
|
+
const mobile = body.mobile === 1 || body.mobile === true; // téléphone → messages courts + autoplay steps
|
|
2236
|
+
if (body.action === "bot-rate") { // satisfaction (1-5 étoiles) posée depuis le bloc central du viewer
|
|
2237
|
+
const sess = await docbot.getSession(String(body.sessionId || ""));
|
|
2238
|
+
if (!sess || sess.share_slug !== share.slug) return jp(400, { ok: false, error: "session" });
|
|
2239
|
+
const note = Math.max(1, Math.min(5, Number(body.rating) || 0));
|
|
2240
|
+
if (!note) return jp(400, { ok: false, error: "rating" });
|
|
2241
|
+
const cmt = String(body.comment || "").trim().slice(0, 500); // mot facultatif (2e temps du bloc)
|
|
2242
|
+
await PLAYER.db.request("doc_bot_sessions?id=eq." + encodeURIComponent(String(body.sessionId)), { method: "PATCH", headers: { Prefer: "return=minimal" }, body: cmt ? { rating: note, rating_comment: cmt } : { rating: note } });
|
|
2243
|
+
return jp(200, { ok: true });
|
|
2244
|
+
}
|
|
2245
|
+
if (body.action === "bot-history") return jp(200, { ok: true, messages: await docbot.listMessages(String(body.sessionId || "")) });
|
|
2246
|
+
const blang = docbot.I18N_LANGS[String(body.lang || "").toLowerCase()] ? String(body.lang).toLowerCase() : null; // fr/inconnu → null (langue source)
|
|
2247
|
+
if (body.action === "bot-start") return jp(200, { ok: true, ...(await docbot.botStart(share, pages, mobile, String(body.intent || ""), blang)) });
|
|
2248
|
+
// Bascule de langue EN COURS de présentation : renvoie le script (traduit ou FR) — le client
|
|
2249
|
+
// remplace sa liste d'étapes et rejoue le message courant dans la nouvelle langue.
|
|
2250
|
+
if (body.action === "bot-script") {
|
|
2251
|
+
const sess = await docbot.getSession(String(body.sessionId || ""));
|
|
2252
|
+
if (!sess || sess.share_slug !== share.slug) return jp(400, { ok: false, error: "session" });
|
|
2253
|
+
const sp = docbot.applyPron(await docbot.scriptedPayload(share.doc_id, blang, String(body.sessionId)), await docbot.getProfile(share.bot_profile_id));
|
|
2254
|
+
if (!sp) return jp(400, { ok: false, error: "script" });
|
|
2255
|
+
return jp(200, { ok: true, steps: sp.steps, voiceScript: sp.voice, message: sp.hook, closing: sp.closing, messageSay: sp.hookSay, closingSay: sp.closingSay });
|
|
2256
|
+
}
|
|
2257
|
+
if (body.action === "bot-nudge") { const rn = await docbot.botNudge(String(body.sessionId || ""), share, pages, mobile); if (rn.error) return jp(400, { ok: false, error: rn.error }); return jp(200, { ok: true, ...rn }); }
|
|
2258
|
+
if (body.action === "bot-book") { const rb = await docbot.bookSlot(String(body.sessionId || ""), share, String(body.book || "")); if (rb.error) return jp(400, { ok: false, error: rb.error }); return jp(200, { ok: true, ...rb }); }
|
|
2259
|
+
// Formulaire de coordonnées : parcours DÉTERMINISTE (zéro appel IA → réponse immédiate).
|
|
2260
|
+
if (body.action === "bot-contact") { const rc = await docbot.contactLead(String(body.sessionId || ""), share, { name: body.name, email: body.email, phone: body.phone }); if (rc.error) return jp(400, { ok: false, error: rc.error }); return jp(200, { ok: true, ...rc }); }
|
|
2261
|
+
const text = String(body.text || "").slice(0, 1000).trim();
|
|
2262
|
+
if (!text) return jp(400, { ok: false, error: "empty" });
|
|
2263
|
+
const r = await docbot.botSay(String(body.sessionId || ""), share, text, pages, mobile, blang);
|
|
2264
|
+
if (r.error) return jp(400, { ok: false, error: r.error });
|
|
2265
|
+
return jp(200, { ok: true, ...r });
|
|
2266
|
+
} catch { return jp(500, { ok: false }); }
|
|
2267
|
+
}
|
|
2268
|
+
// Assistance (heartbeat) : PUBLIC (l'audience est anonyme). Journalise qui suit / combien de temps / pages vues.
|
|
2269
|
+
// Rate-limit généreux par IP (heartbeat ≈ 145/h/participant) : bloque le spam d'assistants factices sans
|
|
2270
|
+
// gêner un usage normal ; fail-open, et un 429 ici ne dégrade que les stats (pas la présentation).
|
|
2271
|
+
if (body.action === "present-attend") {
|
|
2272
|
+
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2273
|
+
try {
|
|
2274
|
+
const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
|
|
2275
|
+
const allowed = await PLAYER.limits.allow(`patt:${ip}`, 1000, 3600);
|
|
2276
|
+
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2277
|
+
const r = await recordAttendance(String(body.slug || ""), { key: body.key, name: body.name, email: body.email, avatar: body.avatar, isMember: !!body.isMember, isPresenter: !!body.isPresenter });
|
|
2278
|
+
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2279
|
+
} catch { return jp(500, { ok: false }); }
|
|
2280
|
+
}
|
|
2281
|
+
// Gestion des présentations (membre AUTHENTIFIÉ requis) : liste / reprise / transfert / stats / historique doc.
|
|
2282
|
+
if (body.action === "present-list" || body.action === "present-reclaim" || body.action === "present-handover" || body.action === "present-owner-end" || body.action === "present-stats" || body.action === "present-doc-list" || body.action === "present-switch" || body.action === "present-content") {
|
|
2283
|
+
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2284
|
+
try {
|
|
2285
|
+
const u = await PLAYER.identity.verifyToken(req.headers.authorization);
|
|
2286
|
+
if (!u || !u.email) return jp(401, { ok: false, error: "auth" });
|
|
2287
|
+
const isAdmin = PLAYER.identity.isAdmin(u);
|
|
2288
|
+
let r;
|
|
2289
|
+
if (body.action === "present-list") r = { ok: true, presentations: await listActivePresentations(u.email) };
|
|
2290
|
+
else if (body.action === "present-reclaim") r = await reclaimPresentation(String(body.slug || ""), u.email);
|
|
2291
|
+
else if (body.action === "present-owner-end") r = await endPresentationByOwner(String(body.slug || ""), u.email, isAdmin);
|
|
2292
|
+
else if (body.action === "present-stats") r = await presentationStats(String(body.slug || ""));
|
|
2293
|
+
else if (body.action === "present-doc-list") r = { ok: true, presentations: await listPresentationsForDoc(String(body.docId || "")) };
|
|
2294
|
+
else if (body.action === "present-switch") {
|
|
2295
|
+
if (!isAllowedStorageUrl(String(body.fileUrl || ""))) return jp(400, { ok: false, error: "url" });
|
|
2296
|
+
r = await switchPresentationDoc(String(body.slug || ""), u.email, isAdmin, { fileUrl: body.fileUrl, fileName: body.fileName, docTitle: body.docTitle, docId: body.docId });
|
|
2297
|
+
}
|
|
2298
|
+
else if (body.action === "present-content") r = await setPresentationContent(String(body.slug || ""), u.email, isAdmin, body.content);
|
|
2299
|
+
else r = await handoverPresentation(String(body.slug || ""), u.email, body.newOwner);
|
|
2300
|
+
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2301
|
+
} catch { return jp(500, { ok: false }); }
|
|
2302
|
+
}
|
|
2303
|
+
// Chat de présentation (historisé) : n'importe quel participant (présentateur ou audience) poste un
|
|
2304
|
+
// message. Écriture via service role ; anti-spam par IP (60/h). La présentation doit exister.
|
|
2305
|
+
if (body.action === "present-chat") {
|
|
2306
|
+
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2307
|
+
try {
|
|
2308
|
+
const pres = await getPresentation(String(body.slug || ""));
|
|
2309
|
+
if (!pres) return jp(404, { ok: false });
|
|
2310
|
+
const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
|
|
2311
|
+
const allowed = await PLAYER.limits.allow(`pchat:${ip}`, 60, 3600);
|
|
2312
|
+
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2313
|
+
// Le badge « présentateur » n'est accordé QUE si le control_token est valide (sinon n'importe quel
|
|
2314
|
+
// participant pourrait poster un message usurpant le présentateur). Sert aussi au chat verrouillé.
|
|
2315
|
+
const validControl = !!(body.control && require("crypto").createHash("sha256").update(String(body.control)).digest("hex") === pres.control_hash);
|
|
2316
|
+
if (pres.chat_locked && !validControl) return jp(423, { ok: false, error: "locked" });
|
|
2317
|
+
const r = await addMessage(String(body.slug || ""), { name: body.name, email: body.email, avatar: body.avatar, isPresenter: validControl, isMember: !!body.isMember, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment });
|
|
2318
|
+
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2319
|
+
} catch { return jp(500, { ok: false }); }
|
|
2320
|
+
}
|
|
2321
|
+
// Pièce jointe : URL d'upload signée (la présentation doit exister ; rate-limit).
|
|
2322
|
+
if (body.action === "present-upload-url") {
|
|
2323
|
+
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2324
|
+
try {
|
|
2325
|
+
const pres = await getPresentation(String(body.slug || ""));
|
|
2326
|
+
if (!pres) return jp(404, { ok: false });
|
|
2327
|
+
const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
|
|
2328
|
+
const allowed = await PLAYER.limits.allow(`pup:${ip}`, 30, 3600);
|
|
2329
|
+
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2330
|
+
const r = await createUploadUrl(String(body.slug || ""), body.name, body.type);
|
|
2331
|
+
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2332
|
+
} catch { return jp(500, { ok: false }); }
|
|
2333
|
+
}
|
|
2334
|
+
// Chat : éditer / supprimer un message, verrouiller le chat.
|
|
2335
|
+
if (body.action === "present-msg-edit" || body.action === "present-msg-delete" || body.action === "present-chatlock") {
|
|
2336
|
+
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2337
|
+
try {
|
|
2338
|
+
let r;
|
|
2339
|
+
if (body.action === "present-msg-edit") r = await editMessage(String(body.slug || ""), body.msgId, String(body.authorToken || ""), body.body);
|
|
2340
|
+
else if (body.action === "present-msg-delete") r = await deleteMessage(String(body.slug || ""), body.msgId, { authorToken: body.authorToken, control: body.control });
|
|
2341
|
+
else r = await setChatLock(String(body.slug || ""), String(body.control || ""), !!body.locked);
|
|
2342
|
+
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2343
|
+
} catch { return jp(500, { ok: false }); }
|
|
2344
|
+
}
|
|
2345
|
+
// Réaction emoji (toggle) sur un message du chat de présentation.
|
|
2346
|
+
if (body.action === "present-react") {
|
|
2347
|
+
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2348
|
+
try {
|
|
2349
|
+
const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
|
|
2350
|
+
const allowed = await PLAYER.limits.allow(`preact:${ip}`, 200, 3600);
|
|
2351
|
+
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2352
|
+
const r = await toggleReaction(String(body.slug || ""), body.msgId, body.emoji, body.reactor);
|
|
2353
|
+
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2354
|
+
} catch { return jp(500, { ok: false }); }
|
|
2355
|
+
}
|
|
2356
|
+
// ── LIENS DE PARTAGE TRACÉS (un par destinataire) ────────────────────────────────────────
|
|
2357
|
+
// Ces actions vivaient dans la route de synchronisation du studio, derrière son modèle de
|
|
2358
|
+
// droits maison — inappelables par un autre hôte. Elles appartiennent au player : c'est lui
|
|
2359
|
+
// qui fabrique les liens, les sert et les trace.
|
|
2360
|
+
//
|
|
2361
|
+
// ⚠️ QUI a le droit de diffuser un document est en revanche une règle de l'HÔTE, pas du
|
|
2362
|
+
// player : elle passe par le contexte (`identity.canManageShares`). Le player se contente de
|
|
2363
|
+
// vérifier le jeton. Sans réponse de l'hôte : refus.
|
|
2364
|
+
if (String(body.action || "").startsWith("docshare.")) {
|
|
2365
|
+
const jd = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2366
|
+
try {
|
|
2367
|
+
const u = await PLAYER.identity.verifyToken(req.headers.authorization);
|
|
2368
|
+
if (!u || !u.email) return jd(401, { ok: false, error: "auth" });
|
|
2369
|
+
// L'action est transmise telle quelle (`create`, `revoke`…) : l'hôte peut séparer
|
|
2370
|
+
// l'envoi d'un document — acte commercial ordinaire — de l'administration des liens.
|
|
2371
|
+
const acte = String(body.action || "").slice("docshare.".length);
|
|
2372
|
+
if (!(await PLAYER.identity.canManageShares(u, acte))) return jd(403, { ok: false, error: "Action non autorisée pour ce rôle." });
|
|
2373
|
+
if (body.action === "docshare.setauth") {
|
|
2374
|
+
return jd(200, await setShareAuth(String(body.slug || ""), !!body.requireAuth));
|
|
2375
|
+
}
|
|
2376
|
+
if (body.action === "docshare.overview") {
|
|
2377
|
+
return jd(200, { ok: true, byDoc: await docOverview() });
|
|
2378
|
+
}
|
|
2379
|
+
if (body.action === "docshare.sessions") {
|
|
2380
|
+
return jd(200, { ok: true, sessions: await listSessionsForDoc(String(body.docId || "")) });
|
|
2381
|
+
}
|
|
2382
|
+
if (body.action === "docshare.list") {
|
|
2383
|
+
const docId = String(body.docId || "");
|
|
2384
|
+
// DEUX portées, et c'est l'hôte qui tranche : « tous les liens » est un acte
|
|
2385
|
+
// d'administration, « mes liens » un acte commercial ordinaire. Sans cette distinction,
|
|
2386
|
+
// un commercial verrait à qui d'autre le document a été envoyé — les prospects de ses
|
|
2387
|
+
// collègues. Un hôte qui ne distingue pas répond oui aux deux et retrouve la liste
|
|
2388
|
+
// complète, comme avant.
|
|
2389
|
+
const tout = await PLAYER.identity.canManageShares(u, "list.all");
|
|
2390
|
+
const [data, internal] = await Promise.all([
|
|
2391
|
+
listSharesForDoc(docId, tout ? null : u.email),
|
|
2392
|
+
internalStatsForDoc(docId).catch(() => null),
|
|
2393
|
+
]);
|
|
2394
|
+
return jd(200, { ok: true, ...data, internal, scope: tout ? "all" : "mine" });
|
|
2395
|
+
}
|
|
2396
|
+
// « Répétition générale » : UN lien de test par document (réutilisé, re-patché avec le fichier et
|
|
2397
|
+
// l'agent ACTUELS à chaque ouverture). Sessions/leads flaggés is_test → exclus stats/notifications.
|
|
2398
|
+
if (body.action === "docshare.test") {
|
|
2399
|
+
const docId = String(body.docId || "");
|
|
2400
|
+
if (!docId || !body.fileUrl) return jd(400, { ok: false, error: "docId/fileUrl requis" });
|
|
2401
|
+
const ex = await PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${encodeURIComponent(docId)}&is_test=eq.true&select=slug&limit=1`);
|
|
2402
|
+
if (Array.isArray(ex) && ex[0]) {
|
|
2403
|
+
await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(ex[0].slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, bot_enabled: true, bot_guided: true, bot_profile_id: (body.profileId || "").trim() || null, revoked: false } });
|
|
2404
|
+
return jd(200, { ok: true, slug: ex[0].slug });
|
|
2405
|
+
}
|
|
2406
|
+
const t = await createShare({ docId, docTitle: body.docTitle, fileUrl: body.fileUrl, fileName: body.fileName, recipientName: "Répétition (test)", createdBy: u.email, bot: true, guided: true, profileId: body.profileId, isTest: true });
|
|
2407
|
+
return jd(200, { ok: true, slug: t.slug });
|
|
2408
|
+
}
|
|
2409
|
+
if (body.action === "docshare.revoke") {
|
|
2410
|
+
await revokeShare(String(body.slug || ""));
|
|
2411
|
+
return jd(200, { ok: true });
|
|
2412
|
+
}
|
|
2413
|
+
const { slug } = await createShare({ brandKey: body.brandKey, docId: body.docId, docTitle: body.docTitle, fileUrl: body.fileUrl, fileName: body.fileName, recipientEmail: body.recipientEmail, recipientName: body.recipientName, createdBy: u.email, bot: body.bot, botScript: body.botScript, guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload, videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark });
|
|
2414
|
+
return jd(200, { ok: true, slug });
|
|
2415
|
+
} catch { return jd(500, { ok: false }); }
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// Re-partage (forward depuis la visionneuse) : crée un lien enfant tracé, et envoie l'email via 3D
|
|
2419
|
+
// Discovery si demandé (body.send). Anti-spam : contenu templé + RATE LIMIT par IP (8/h).
|
|
2420
|
+
if (body.action === "reshare") {
|
|
2421
|
+
const mail = String(body.email || "").trim().toLowerCase();
|
|
2422
|
+
const j = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2423
|
+
if (!/.+@.+\..+/.test(mail)) return j(400, { ok: false, error: "email" });
|
|
2424
|
+
const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
|
|
2425
|
+
const allowed = await PLAYER.limits.allow(`reshare:${ip}`, 8, 3600);
|
|
2426
|
+
if (!allowed) return j(429, { ok: false, error: "rate", message: "Trop de partages, réessayez plus tard." });
|
|
2427
|
+
let out = null;
|
|
2428
|
+
try { out = await createReshare(body.slug || slug, { email: mail, name: body.name }); } catch { /* parent introuvable */ }
|
|
2429
|
+
if (!out) return j(404, { ok: false });
|
|
2430
|
+
let sent = false;
|
|
2431
|
+
if (body.send) {
|
|
2432
|
+
try {
|
|
2433
|
+
const parent = await getShareBySlug(body.slug || slug);
|
|
2434
|
+
const origin = `https://${req.headers.host}`;
|
|
2435
|
+
const r = await sendReshareEmail({ parent, childSlug: out.slug, origin, toEmail: mail, toName: body.name });
|
|
2436
|
+
sent = !!(r && r.sent);
|
|
2437
|
+
} catch { /* best-effort : le lien existe quand même */ }
|
|
2438
|
+
}
|
|
2439
|
+
return j(200, { ok: true, slug: out.slug, sent });
|
|
2440
|
+
}
|
|
2441
|
+
const ua0 = req.headers["user-agent"];
|
|
2442
|
+
const ip0 = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "";
|
|
2443
|
+
// Consultation INTERNE (aperçu interne) : session dédiée, séparée des stats prospects. Pas de slug.
|
|
2444
|
+
if (body.internal && body.event === "session") {
|
|
2445
|
+
try { await upsertInternalSession({ sessionId: body.sessionId, docId: body.docId, userEmail: body.email, userName: body.name, numPages: body.numPages, maxPage: body.maxPage, totalSeconds: body.totalSeconds, pagesTime: body.pagesTime }, { ip: ip0, ua: ua0 }); } catch { /* best-effort */ }
|
|
2446
|
+
res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end('{"ok":true}');
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2449
|
+
const share = await getShareBySlug(body.slug || slug);
|
|
2450
|
+
if (share && !share.is_test) { // répétition générale : la lecture de test ne compte pas dans les stats
|
|
2451
|
+
try {
|
|
2452
|
+
// 'session' = résumé riche (temps par page, appareil) → upsert ; open/page/heartbeat → journal léger (funnel/overview).
|
|
2453
|
+
if (body.event === "session") await upsertSession(share, { sessionId: body.sessionId, numPages: body.numPages, maxPage: body.maxPage, totalSeconds: body.totalSeconds, pagesTime: body.pagesTime }, { ip: ip0, ua: ua0 });
|
|
2454
|
+
else await logView(share, { event: body.event, page: body.page, maxPage: body.maxPage, seconds: body.seconds, sessionId: body.sessionId, ua: ua0 });
|
|
2455
|
+
} catch { /* best-effort */ }
|
|
2456
|
+
}
|
|
2457
|
+
res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end('{"ok":true}');
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
// ── CARTE D'IDENTITÉ (`?contract=1`) ─────────────────────────────────────────────────────
|
|
2462
|
+
// La règle 4 du contrat demande à l'hôte d'épingler la version qu'il vise et de le VÉRIFIER.
|
|
2463
|
+
// Sans point d'interrogation, cette règle était une intention : un hôte ne pouvait pas écrire
|
|
2464
|
+
// le test. Placé en tête à dessein — c'est un outil de diagnostic, il doit répondre même
|
|
2465
|
+
// quand le reste ne va pas, et ne demande donc ni session ni base.
|
|
2466
|
+
//
|
|
2467
|
+
// Ce qu'il contient : de quoi DÉCIDER (le numéro de contrat, les capacités présentes), rien
|
|
2468
|
+
// qui aide à attaquer — aucune URL, aucun secret, aucun nom d'hôte. Les greffons sont donnés
|
|
2469
|
+
// en booléens parce qu'un hôte doit pouvoir refuser de démarrer si le mur d'accès manque
|
|
2470
|
+
// alors qu'il compte dessus.
|
|
2471
|
+
if (String(q.contract || "") === "1") {
|
|
2472
|
+
res.statusCode = 200;
|
|
2473
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
2474
|
+
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
2475
|
+
const p = PLAYER.plugins || {};
|
|
2476
|
+
res.end(JSON.stringify({
|
|
2477
|
+
product: "discovery-media-player",
|
|
2478
|
+
// ⚠️ LE champ à épingler. Il ne bouge QUE sur une rupture (règle 2) : ajouter une action,
|
|
2479
|
+
// un paramètre ou un motif de refus ne le change pas.
|
|
2480
|
+
contract: 1,
|
|
2481
|
+
version: PLAYER_VERSION,
|
|
2482
|
+
// Ce que cette instance sait faire. Un hôte teste la présence, jamais l'ordre.
|
|
2483
|
+
capabilities: [
|
|
2484
|
+
"docshare", "presentations", "embed-denied", "host-fetch", "brand-reference",
|
|
2485
|
+
],
|
|
2486
|
+
// Greffons de l'hôte : présents ou coupés (PLAYER_PLUGINS_OFF). Booléens uniquement.
|
|
2487
|
+
plugins: {
|
|
2488
|
+
bot: !!p.bot, visitors: !!p.visitors, brandIntro: !!p.brandIntro,
|
|
2489
|
+
botBrowser: !!p.botBrowser, providerQuotas: !!p.providerQuotas,
|
|
2490
|
+
},
|
|
2491
|
+
}));
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// ⚠️ Lu ICI, une seule fois, et AVANT toute branche capable de refuser. Il était calculé plus
|
|
2496
|
+
// bas, dans le seul chemin du lien tracé : l'aperçu interne et la page d'audience refusaient
|
|
2497
|
+
// donc en silence. Signalé par un hôte dont la visionneuse interne utilise
|
|
2498
|
+
// précisément `?preview=1&embed=1` — le premier mode qu'un nouvel hôte exerce, et celui où un
|
|
2499
|
+
// refus de configuration ressemble le plus à une instance injoignable.
|
|
2500
|
+
const embed = String(q.embed || "") === "1";
|
|
2501
|
+
|
|
2502
|
+
// Mode « Présenter » côté AUDIENCE : `?present=<slug>` → page live (suit le présentateur via Realtime) ;
|
|
2503
|
+
// `&file=1` → stream le PDF de la présentation (Range, même origine pour pdf.js).
|
|
2504
|
+
if (q.present) {
|
|
2505
|
+
const pres = await getPresentation(String(q.present));
|
|
2506
|
+
if (!pres) return sendRefusal(res, "ended", embed);
|
|
2507
|
+
// Historique du chat (chargé au join par présentateur ET audience).
|
|
2508
|
+
// ÉTAT de la présentation, relu par l'audience à la reconnexion ou au retour d'onglet.
|
|
2509
|
+
// C'est la porte qui permettra de retirer la lecture anonyme des tables : l'audience n'a
|
|
2510
|
+
// plus besoin de lire `doc_presentations` pour connaître la page courante. On ne renvoie
|
|
2511
|
+
// QUE ce que l'audience doit savoir — ni propriétaire, ni jeton, ni horodatages internes.
|
|
2512
|
+
if (String(q.state || "") === "1") {
|
|
2513
|
+
res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.setHeader("Cache-Control", "no-store");
|
|
2514
|
+
res.end(JSON.stringify({ ok: true, state: {
|
|
2515
|
+
active: pres.active !== false,
|
|
2516
|
+
current_page: pres.current_page || 1,
|
|
2517
|
+
content: pres.content || null,
|
|
2518
|
+
file_url: pres.file_url || null,
|
|
2519
|
+
file_name: pres.file_name || null,
|
|
2520
|
+
doc_title: pres.doc_title || null,
|
|
2521
|
+
updated_at: pres.updated_at || null,
|
|
2522
|
+
} }));
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
if (String(q.chat || "") === "1") {
|
|
2526
|
+
res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.setHeader("Cache-Control", "no-store");
|
|
2527
|
+
res.end(JSON.stringify({ ok: true, messages: await listMessages(String(q.present)), locked: !!pres.chat_locked }));
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
if (String(q.file || "") === "1") {
|
|
2531
|
+
if (!isAllowedStorageUrl(pres.file_url)) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
|
|
2532
|
+
const range = req.headers["range"];
|
|
2533
|
+
const r = await PLAYER.storage.fetchFile(pres.file_url, { range });
|
|
2534
|
+
await relayerFichier(res, r, null);
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
const supaUrl = process.env.SUPABASE_URL || "";
|
|
2538
|
+
const supaKey = process.env.SUPABASE_PUBLISHABLE_KEY || "";
|
|
2539
|
+
let alogo = ""; try { alogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
|
|
2540
|
+
const anonce = crypto.randomBytes(16).toString("base64");
|
|
2541
|
+
return sendPresentHtml(res, presentHtml(pres, anonce, alogo, supaUrl, supaKey), anonce, supaUrl, originOf(alogo));
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// Aperçu interne (depuis la bibliothèque) : même visionneuse pdf.js, SANS lien tracé ni suivi.
|
|
2545
|
+
// `?preview=1&url=<storage public>&name=&title=` → HTML ; `&stream=1` → stream le fichier (Range).
|
|
2546
|
+
if (String(q.preview || "") === "1") {
|
|
2547
|
+
const url = String(q.url || "");
|
|
2548
|
+
if (!isAllowedStorageUrl(url)) return sendRefusal(res, "url-not-allowed", embed);
|
|
2549
|
+
if (String(q.stream || "") === "1") {
|
|
2550
|
+
const range = req.headers["range"];
|
|
2551
|
+
const r = await PLAYER.storage.fetchFile(url, { range });
|
|
2552
|
+
await relayerFichier(res, r, dispositionInline(q.name));
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
const supaUrl = process.env.SUPABASE_URL || "";
|
|
2556
|
+
const supaKey = process.env.SUPABASE_PUBLISHABLE_KEY || "";
|
|
2557
|
+
const pseudo = { preview: true, slug: "", file_name: String(q.name || "document.pdf"), doc_title: String(q.title || q.name || "Document"), raw_url: url, doc_id: String(q.docId || ""), presenter_name: String(q.by || ""), presenter_avatar: String(q.av || ""), internal_email: String(q.uemail || ""), supa_url: supaUrl, supa_key: supaKey, auto_present: String(q.autopresent || "") === "1", resume_slug: String(q.resume || ""), stream_url: `/api/doc?preview=1&stream=1&url=${encodeURIComponent(url)}&name=${encodeURIComponent(String(q.name || ""))}` };
|
|
2558
|
+
let plogo = ""; try { plogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
|
|
2559
|
+
const pnonce = crypto.randomBytes(16).toString("base64");
|
|
2560
|
+
// Aperçu interne : CSP relâchée (supabase-js jsdelivr + Realtime wss) pour la présence + le chat live,
|
|
2561
|
+
// framing MÊME ORIGINE (iframe DocViewer). La visionneuse PUBLIQUE /doc/:slug garde sa CSP stricte.
|
|
2562
|
+
return sendPresentHtml(res, viewerHtml(pseudo, pnonce, plogo), pnonce, supaUrl, originOf(plogo), "'self'");
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
const share = slug ? await getShareBySlug(slug) : null;
|
|
2566
|
+
if (!share) return sendRefusal(res, "revoked", embed);
|
|
2567
|
+
|
|
2568
|
+
// Soft wall : un document require_auth n'est servi qu'à un visiteur au jeton valide.
|
|
2569
|
+
// Mur d'accès visiteur — greffon. SANS lui, un document « compte requis » ne doit surtout PAS
|
|
2570
|
+
// devenir librement lisible : on FERME (404) au lieu de dégrader en accès ouvert. Fail-closed.
|
|
2571
|
+
const visitors = PLAYER.plugins.visitors;
|
|
2572
|
+
const visitor = visitors ? visitors.currentVisitor(req) : null;
|
|
2573
|
+
if (share.require_auth === true && !visitors) return sendRefusal(res, "auth-unavailable", embed);
|
|
2574
|
+
const gated = share.require_auth === true && !visitor;
|
|
2575
|
+
|
|
2576
|
+
if (String(q.file || "") === "1") {
|
|
2577
|
+
if (gated) { res.statusCode = 401; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ ok: false, error: "auth" })); return; }
|
|
2578
|
+
// Stream depuis le Storage en RELAYANT les requêtes Range → pdf.js charge progressivement (les 1res
|
|
2579
|
+
// pages s'affichent sans télécharger tout le PDF) → affichage bien plus rapide.
|
|
2580
|
+
const range = req.headers["range"];
|
|
2581
|
+
const r = await PLAYER.storage.fetchFile(share.file_url, { range });
|
|
2582
|
+
await relayerFichier(res, r, dispositionInline(share.file_name));
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// Soft wall : contenu réservé → on sert la page de connexion visiteur (email + code)
|
|
2587
|
+
// AVANT de charger le lecteur. À la vérification, le cookie est posé → un reload lève le mur.
|
|
2588
|
+
if (gated) {
|
|
2589
|
+
let wlogo = ""; try { wlogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
|
|
2590
|
+
const wnonce = crypto.randomBytes(16).toString("base64");
|
|
2591
|
+
const gcid = visitors.googleClientId();
|
|
2592
|
+
// Intégré : le mur reste affiché (le visiteur peut s'y connecter sur place) mais il DIT à
|
|
2593
|
+
// l'hôte que le document est retenu — sinon l'hôte croit à une panne et replie sur son
|
|
2594
|
+
// lecteur, qui lui ouvrirait le document que ce mur protège.
|
|
2595
|
+
const wall = softWallHtml(share, wnonce, wlogo, gcid)
|
|
2596
|
+
+ (embed ? `<script nonce="${wnonce}">try{parent.postMessage({type:"3dd-doc-embed-denied",reason:"auth-required"},"*")}catch(e){}</script>` : "");
|
|
2597
|
+
return sendSoftWallHtml(res, wall, wnonce, [originOf(wlogo), originOf(share.brand_logo)].filter(Boolean).join(" "), embed ? embedFrameAncestors() : null);
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
// Identité du profil d'assistant (nom, avatar, tagline, couleur) → header du chat, bulle flottante,
|
|
2601
|
+
// mini-avatars des messages. Best-effort : sans profil, identité par défaut.
|
|
2602
|
+
if (share.bot_enabled && docbot) {
|
|
2603
|
+
try {
|
|
2604
|
+
const bp = await docbot.getProfile(share.bot_profile_id);
|
|
2605
|
+
share.bot_name = bp.name; share.bot_avatar = bp.avatar_url; share.bot_tagline = bp.tagline; share.bot_accent = bp.accent_color;
|
|
2606
|
+
share.bot_greeting = (bp.behavior && bp.behavior.greeting) || ""; // accueil pré-chargé → affiché INSTANTANÉMENT côté client
|
|
2607
|
+
share.bot_greeting_doc = (bp.behavior && bp.behavior.greeting_doc) || ""; // accueil DÉDIÉ à la présentation d'un document (plus court que l'accueil chat)
|
|
2608
|
+
share.bot_vphoto = (bp.behavior && bp.behavior.video && bp.behavior.video.photo) || ""; // photo PRÉSENTATEUR (webcam / split mobile) — plan large, environnement réel
|
|
2609
|
+
// Les PORTES proposent « En vidéo » seulement si des clips existent réellement (manifeste FR).
|
|
2610
|
+
try { const av = PLAYER.plugins.avatarClips; const cl = av && await av.clipsFor(share.doc_id, null, bp); share.bot_vclips = !!(cl && Object.keys(cl.clips || {}).length); } catch { share.bot_vclips = false; }
|
|
2611
|
+
share.bot_karaoke = (bp.behavior && bp.behavior.karaoke) || ""; // style des sous-titres voix choisi PAR AGENT
|
|
2612
|
+
} catch { /* identité par défaut */ }
|
|
2613
|
+
}
|
|
2614
|
+
// Marque du loader : celle du client (registre, résolue MAINTENANT donc toujours à jour),
|
|
2615
|
+
// sinon le logo recopié dans le lien, sinon celle de l'instance.
|
|
2616
|
+
try {
|
|
2617
|
+
const marque = await brands.brandForShare(share);
|
|
2618
|
+
if (marque) { share.brand_logo = marque.logo; share.brand_name = marque.name; share.brand_dark = marque.dark; }
|
|
2619
|
+
} catch { /* le loader dégrade, il n'empêche pas de lire */ }
|
|
2620
|
+
let logoUrl = ""; try { logoUrl = await PLAYER.branding.logo(); } catch { /* sans logo */ }
|
|
2621
|
+
// Pitch du document (résumé de l'analyse IA) : personnalise l'écran d'accueil — le prospect comprend
|
|
2622
|
+
// tout de suite CE QU'EST ce document avant de choisir comment le découvrir.
|
|
2623
|
+
let pitch = "";
|
|
2624
|
+
if (share.bot_enabled && share.doc_id && docbot) {
|
|
2625
|
+
try {
|
|
2626
|
+
const fiche = await docbot.getDocFiche(share.doc_id);
|
|
2627
|
+
pitch = String((fiche && fiche.brief && fiche.brief.summary) || "").replace(/\s+/g, " ").trim();
|
|
2628
|
+
if (pitch.length > 190) { const cut = pitch.slice(0, 190).lastIndexOf(". "); pitch = cut > 90 ? pitch.slice(0, cut + 1) : pitch.slice(0, 187) + "…"; }
|
|
2629
|
+
} catch { /* pas de fiche → accueil générique */ }
|
|
2630
|
+
}
|
|
2631
|
+
// ?embed=1 : la visionneuse est en surimpression dans une page hôte (le plan d'un
|
|
2632
|
+
// lot dans une expérience 3D) → sa barre porte la croix de sortie.
|
|
2633
|
+
share.embed = embed;
|
|
2634
|
+
const nonce = crypto.randomBytes(16).toString("base64");
|
|
2635
|
+
// FRAMING. Page publique : 'self' seulement (anti-clickjacking). Mode INTÉGRÉ
|
|
2636
|
+
// (?embed=1) : les expériences ne vivent pas toutes derrière le proxy
|
|
2637
|
+
// /experience — certaines expériences, les préversions et les liens de diffusion sont servis
|
|
2638
|
+
// sur leurs propres domaines Vercel, où 'self' bloquait l'overlay des plans
|
|
2639
|
+
// (page blanche constatée le 10/08). On y autorise donc les domaines Vercel
|
|
2640
|
+
// (+ extras via DOC_FRAME_ANCESTORS, séparés par des espaces — futurs domaines
|
|
2641
|
+
// custom d'XP). La CSP frame-ancestors PRIME sur le X-Frame-Options SAMEORIGIN
|
|
2642
|
+
// global du vercel.json (spec : XFO ignoré quand frame-ancestors est présent).
|
|
2643
|
+
const frameAncestors = share.embed
|
|
2644
|
+
? ["'self'", "https://*.vercel.app"]
|
|
2645
|
+
.concat(String(process.env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean))
|
|
2646
|
+
.join(" ")
|
|
2647
|
+
: "'self'";
|
|
2648
|
+
return sendHtml(res, 200, viewerHtml(share, nonce, logoUrl, pitch), `'nonce-${nonce}'`, [originOf(logoUrl), originOf(share.bot_avatar), originOf(share.brand_logo)].filter(Boolean).join(" "), frameAncestors);
|
|
2649
|
+
} catch (error) {
|
|
2650
|
+
try { await PLAYER.errors.capture(error, { route: "doc", method: req.method }); } catch { /* ignore */ }
|
|
2651
|
+
res.statusCode = 500; res.end("Erreur");
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
module.exports = { handler, init };
|
|
2656
|
+
|
|
2657
|
+
// redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).
|