Tafita1206 commited on
Commit
d5ea5e4
·
verified ·
1 Parent(s): 4c5546c

Rename public/user_client.html to public/user.html

Browse files
Files changed (2) hide show
  1. public/user.html +1411 -0
  2. public/user_client.html +0 -1260
public/user.html ADDED
@@ -0,0 +1,1411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>SecureView — Mon Espace</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
9
+ <style>
10
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
11
+
12
+ /* ══ CSS VARIABLES — LIGHT & DARK ══ */
13
+ :root {
14
+ --bg: #f0f4f8;
15
+ --surface: #ffffff;
16
+ --panel: #f7fafc;
17
+ --panel2: #eef2f7;
18
+ --border: #dde4ed;
19
+ --border2: #c9d4e0;
20
+ --text: #1a2535;
21
+ --muted: #6b7a90;
22
+ --accent: #2563eb;
23
+ --accent2: #1d4ed8;
24
+ --accent-glow: rgba(37,99,235,0.13);
25
+ --green: #16a34a;
26
+ --green-bg: rgba(22,163,74,0.09);
27
+ --red: #dc2626;
28
+ --red-bg: rgba(220,38,38,0.08);
29
+ --orange: #ea580c;
30
+ --gold: #b45309;
31
+ --shadow: 0 4px 24px rgba(30,50,80,0.10);
32
+ --shadow-lg: 0 12px 48px rgba(30,50,80,0.16);
33
+ --font: 'Syne', sans-serif;
34
+ --mono: 'IBM Plex Mono', monospace;
35
+ --radius: 16px;
36
+ --radius-sm: 10px;
37
+ }
38
+
39
+ [data-theme="dark"] {
40
+ --bg: #07090c;
41
+ --surface: #0d1117;
42
+ --panel: #111820;
43
+ --panel2: #161e28;
44
+ --border: #1a2230;
45
+ --border2: #243040;
46
+ --text: #cdd6e0;
47
+ --muted: #4a5e72;
48
+ --accent: #3b82f6;
49
+ --accent2: #2563eb;
50
+ --accent-glow: rgba(59,130,246,0.15);
51
+ --green: #22c55e;
52
+ --green-bg: rgba(34,197,94,0.09);
53
+ --red: #f87171;
54
+ --red-bg: rgba(248,113,113,0.08);
55
+ --orange: #fb923c;
56
+ --gold: #c9a84c;
57
+ --shadow: 0 4px 24px rgba(0,0,0,0.4);
58
+ --shadow-lg: 0 12px 48px rgba(0,0,0,0.55);
59
+ }
60
+
61
+ html, body {
62
+ height: 100%;
63
+ background: var(--bg);
64
+ color: var(--text);
65
+ font-family: var(--font);
66
+ font-size: 14px;
67
+ overflow-x: hidden;
68
+ transition: background 0.3s, color 0.3s;
69
+ }
70
+
71
+ /* ══ THEME TOGGLE ══ */
72
+ .theme-btn {
73
+ position: fixed;
74
+ top: 18px;
75
+ right: 18px;
76
+ z-index: 9998;
77
+ width: 40px;
78
+ height: 40px;
79
+ border-radius: 50%;
80
+ border: 1.5px solid var(--border);
81
+ background: var(--surface);
82
+ color: var(--text);
83
+ font-size: 18px;
84
+ cursor: pointer;
85
+ display: flex;
86
+ align-items: center;
87
+ justify-content: center;
88
+ box-shadow: var(--shadow);
89
+ transition: all 0.2s;
90
+ }
91
+ .theme-btn:hover { border-color: var(--accent); transform: scale(1.08); }
92
+
93
+ /* ══ LOGIN SCREEN ══ */
94
+ #login-screen {
95
+ position: fixed;
96
+ inset: 0;
97
+ background: var(--bg);
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ z-index: 9000;
102
+ padding: 20px;
103
+ }
104
+
105
+ .login-wrap {
106
+ width: 100%;
107
+ max-width: 420px;
108
+ animation: fadeUp 0.5s ease both;
109
+ }
110
+
111
+ @keyframes fadeUp {
112
+ from { opacity:0; transform:translateY(28px); }
113
+ to { opacity:1; transform:translateY(0); }
114
+ }
115
+
116
+ .login-brand {
117
+ text-align: center;
118
+ margin-bottom: 36px;
119
+ }
120
+ .login-icon {
121
+ width: 68px;
122
+ height: 68px;
123
+ border-radius: 20px;
124
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: center;
128
+ font-size: 32px;
129
+ margin: 0 auto 16px;
130
+ box-shadow: 0 8px 24px var(--accent-glow);
131
+ }
132
+ .login-title {
133
+ font-size: 26px;
134
+ font-weight: 800;
135
+ letter-spacing: -0.5px;
136
+ color: var(--text);
137
+ }
138
+ .login-sub {
139
+ font-size: 13px;
140
+ color: var(--muted);
141
+ margin-top: 6px;
142
+ line-height: 1.5;
143
+ }
144
+
145
+ .login-card {
146
+ background: var(--surface);
147
+ border: 1.5px solid var(--border);
148
+ border-radius: 22px;
149
+ padding: 32px 28px;
150
+ box-shadow: var(--shadow-lg);
151
+ }
152
+
153
+ .field-label {
154
+ font-size: 11px;
155
+ font-weight: 700;
156
+ text-transform: uppercase;
157
+ letter-spacing: 0.9px;
158
+ color: var(--muted);
159
+ margin-bottom: 8px;
160
+ display: block;
161
+ }
162
+ .field-row {
163
+ margin-bottom: 18px;
164
+ }
165
+ .input-wrap {
166
+ position: relative;
167
+ }
168
+ .input-icon {
169
+ position: absolute;
170
+ left: 13px;
171
+ top: 50%;
172
+ transform: translateY(-50%);
173
+ font-size: 15px;
174
+ color: var(--muted);
175
+ pointer-events: none;
176
+ }
177
+ .text-input {
178
+ width: 100%;
179
+ padding: 12px 14px 12px 38px;
180
+ background: var(--panel);
181
+ border: 1.5px solid var(--border);
182
+ border-radius: var(--radius-sm);
183
+ color: var(--text);
184
+ font-family: var(--mono);
185
+ font-size: 13px;
186
+ letter-spacing: 0.5px;
187
+ transition: all 0.2s;
188
+ outline: none;
189
+ }
190
+ .text-input:focus {
191
+ border-color: var(--accent);
192
+ background: var(--surface);
193
+ box-shadow: 0 0 0 3px var(--accent-glow);
194
+ }
195
+ .text-input::placeholder { color: var(--muted); font-family: var(--font); letter-spacing: 0; }
196
+ .text-input.error { border-color: var(--red); box-shadow: 0 0 0 3px var(--red-bg); }
197
+
198
+ .login-hint {
199
+ font-size: 11px;
200
+ color: var(--muted);
201
+ margin-top: 5px;
202
+ line-height: 1.5;
203
+ }
204
+
205
+ .btn-login {
206
+ width: 100%;
207
+ padding: 14px;
208
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
209
+ color: #fff;
210
+ border: none;
211
+ border-radius: var(--radius-sm);
212
+ font-family: var(--font);
213
+ font-size: 14px;
214
+ font-weight: 700;
215
+ letter-spacing: 0.4px;
216
+ cursor: pointer;
217
+ transition: all 0.18s;
218
+ margin-top: 6px;
219
+ display: flex;
220
+ align-items: center;
221
+ justify-content: center;
222
+ gap: 8px;
223
+ }
224
+ .btn-login:hover { filter: brightness(1.1); transform: translateY(-1px); box-shadow: 0 6px 20px var(--accent-glow); }
225
+ .btn-login:active { transform: translateY(0); }
226
+ .btn-login:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
227
+
228
+ .login-error {
229
+ background: var(--red-bg);
230
+ border: 1px solid rgba(220,38,38,0.25);
231
+ color: var(--red);
232
+ padding: 10px 14px;
233
+ border-radius: 8px;
234
+ font-size: 12px;
235
+ font-weight: 600;
236
+ margin-top: 14px;
237
+ display: none;
238
+ animation: shake 0.35s;
239
+ }
240
+ .login-error.show { display: block; }
241
+ @keyframes shake { 0%,100%{transform:translateX(0)} 25%,75%{transform:translateX(-6px)} 50%{transform:translateX(6px)} }
242
+
243
+ .spinner {
244
+ width: 16px;
245
+ height: 16px;
246
+ border: 2px solid rgba(255,255,255,0.3);
247
+ border-top-color: #fff;
248
+ border-radius: 50%;
249
+ animation: spin 0.7s linear infinite;
250
+ }
251
+ @keyframes spin { to { transform: rotate(360deg); } }
252
+
253
+ /* ══ MAIN APP ══ */
254
+ #app {
255
+ display: none;
256
+ min-height: 100vh;
257
+ flex-direction: column;
258
+ }
259
+
260
+ /* HEADER */
261
+ .header {
262
+ background: var(--surface);
263
+ border-bottom: 1.5px solid var(--border);
264
+ padding: 14px 24px;
265
+ display: flex;
266
+ align-items: center;
267
+ justify-content: space-between;
268
+ position: sticky;
269
+ top: 0;
270
+ z-index: 100;
271
+ box-shadow: 0 2px 12px rgba(0,0,0,0.06);
272
+ gap: 12px;
273
+ }
274
+ .header-left { display: flex; align-items: center; gap: 12px; }
275
+ .h-logo {
276
+ width: 36px;
277
+ height: 36px;
278
+ border-radius: 10px;
279
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
280
+ display: flex;
281
+ align-items: center;
282
+ justify-content: center;
283
+ font-size: 18px;
284
+ box-shadow: 0 3px 10px var(--accent-glow);
285
+ }
286
+ .h-name { font-size: 17px; font-weight: 800; letter-spacing: -0.3px; }
287
+ .h-badge {
288
+ font-size: 9px;
289
+ background: var(--accent-glow);
290
+ color: var(--accent);
291
+ border: 1px solid rgba(37,99,235,0.25);
292
+ padding: 2px 8px;
293
+ border-radius: 20px;
294
+ font-weight: 700;
295
+ letter-spacing: 0.8px;
296
+ }
297
+ .header-right { display: flex; align-items: center; gap: 10px; }
298
+ .ws-pill {
299
+ display: flex;
300
+ align-items: center;
301
+ gap: 6px;
302
+ font-size: 11px;
303
+ padding: 5px 11px;
304
+ border-radius: 20px;
305
+ border: 1px solid;
306
+ font-weight: 600;
307
+ }
308
+ .ws-pill.on { color: var(--green); border-color: rgba(22,163,74,0.3); background: var(--green-bg); }
309
+ .ws-pill.off { color: var(--red); border-color: rgba(220,38,38,0.25); background: var(--red-bg); }
310
+ .ws-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; animation: blink 1.8s infinite; }
311
+ @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.25} }
312
+ .btn-logout {
313
+ background: var(--red-bg);
314
+ color: var(--red);
315
+ border: 1px solid rgba(220,38,38,0.2);
316
+ padding: 7px 13px;
317
+ border-radius: 8px;
318
+ font-size: 11px;
319
+ font-weight: 700;
320
+ cursor: pointer;
321
+ font-family: var(--font);
322
+ transition: all 0.15s;
323
+ }
324
+ .btn-logout:hover { background: rgba(220,38,38,0.16); }
325
+
326
+ /* DEVICE BANNER */
327
+ .device-banner {
328
+ background: var(--surface);
329
+ border-bottom: 1.5px solid var(--border);
330
+ padding: 14px 24px;
331
+ display: flex;
332
+ align-items: center;
333
+ gap: 16px;
334
+ flex-wrap: wrap;
335
+ }
336
+ .dev-avatar {
337
+ width: 48px;
338
+ height: 48px;
339
+ border-radius: 14px;
340
+ background: linear-gradient(135deg, var(--accent-glow), var(--panel2));
341
+ border: 1.5px solid var(--border);
342
+ display: flex;
343
+ align-items: center;
344
+ justify-content: center;
345
+ font-size: 22px;
346
+ flex-shrink: 0;
347
+ }
348
+ .dev-info { flex: 1; min-width: 0; }
349
+ .dev-name { font-size: 17px; font-weight: 700; }
350
+ .dev-id {
351
+ font-family: var(--mono);
352
+ font-size: 10px;
353
+ color: var(--muted);
354
+ margin-top: 2px;
355
+ white-space: nowrap;
356
+ overflow: hidden;
357
+ text-overflow: ellipsis;
358
+ }
359
+ .dev-status-wrap { display: flex; align-items: center; gap: 8px; }
360
+ .status-pill {
361
+ display: flex;
362
+ align-items: center;
363
+ gap: 6px;
364
+ padding: 5px 12px;
365
+ border-radius: 20px;
366
+ font-size: 11px;
367
+ font-weight: 700;
368
+ border: 1px solid;
369
+ }
370
+ .status-pill.online { color: var(--green); border-color: rgba(22,163,74,0.3); background: var(--green-bg); }
371
+ .status-pill.offline { color: var(--muted); border-color: var(--border); background: var(--panel); }
372
+ .status-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
373
+ .status-pill.online .status-dot { animation: blink 1.6s infinite; }
374
+
375
+ /* TABS */
376
+ .tabs {
377
+ background: var(--surface);
378
+ border-bottom: 1.5px solid var(--border);
379
+ padding: 0 24px;
380
+ display: flex;
381
+ gap: 0;
382
+ overflow-x: auto;
383
+ }
384
+ .tab {
385
+ padding: 14px 18px;
386
+ font-size: 11px;
387
+ font-weight: 700;
388
+ cursor: pointer;
389
+ color: var(--muted);
390
+ border-bottom: 3px solid transparent;
391
+ transition: all 0.2s;
392
+ text-transform: uppercase;
393
+ letter-spacing: 0.7px;
394
+ white-space: nowrap;
395
+ }
396
+ .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
397
+ .tab:hover:not(.active) { color: var(--text); }
398
+
399
+ /* CONTENT */
400
+ .content { flex: 1; max-width: 1000px; margin: 0 auto; padding: 24px; width: 100%; }
401
+ .tc { display: none; }
402
+ .tc.active { display: block; }
403
+
404
+ /* SECTION */
405
+ .section-title {
406
+ font-size: 11px;
407
+ font-weight: 700;
408
+ color: var(--accent);
409
+ text-transform: uppercase;
410
+ letter-spacing: 1.1px;
411
+ margin-bottom: 18px;
412
+ display: flex;
413
+ align-items: center;
414
+ gap: 8px;
415
+ }
416
+
417
+ /* BIG ACTION CARDS */
418
+ .action-grid {
419
+ display: grid;
420
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
421
+ gap: 16px;
422
+ margin-bottom: 28px;
423
+ }
424
+ .action-card {
425
+ background: var(--surface);
426
+ border: 1.5px solid var(--border);
427
+ border-radius: var(--radius);
428
+ padding: 24px 20px;
429
+ cursor: pointer;
430
+ transition: all 0.22s;
431
+ display: flex;
432
+ flex-direction: column;
433
+ gap: 10px;
434
+ position: relative;
435
+ overflow: hidden;
436
+ }
437
+ .action-card::before {
438
+ content: '';
439
+ position: absolute;
440
+ inset: 0;
441
+ opacity: 0;
442
+ transition: opacity 0.2s;
443
+ }
444
+ .action-card.blue::before { background: linear-gradient(135deg, rgba(37,99,235,0.04), transparent); }
445
+ .action-card.green::before { background: linear-gradient(135deg, rgba(22,163,74,0.05), transparent); }
446
+ .action-card.orange::before { background: linear-gradient(135deg, rgba(234,88,12,0.05), transparent); }
447
+ .action-card:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow); }
448
+ .action-card:hover::before { opacity: 1; }
449
+ .action-card:disabled, .action-card.disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
450
+ .ac-icon { font-size: 30px; }
451
+ .ac-title { font-size: 16px; font-weight: 700; }
452
+ .ac-desc { font-size: 12px; color: var(--muted); line-height: 1.5; }
453
+ .ac-tag {
454
+ display: inline-block;
455
+ font-size: 10px;
456
+ font-weight: 700;
457
+ padding: 3px 9px;
458
+ border-radius: 20px;
459
+ margin-top: 4px;
460
+ border: 1px solid;
461
+ }
462
+ .ac-tag.blue { color: var(--accent); border-color: rgba(37,99,235,0.25); background: var(--accent-glow); }
463
+ .ac-tag.green { color: var(--green); border-color: rgba(22,163,74,0.25); background: var(--green-bg); }
464
+ .ac-tag.orange { color: var(--orange); border-color: rgba(234,88,12,0.2); background: rgba(234,88,12,0.07); }
465
+
466
+ /* INFO CARD */
467
+ .info-card {
468
+ background: var(--surface);
469
+ border: 1.5px solid var(--border);
470
+ border-radius: var(--radius);
471
+ padding: 20px 22px;
472
+ margin-bottom: 16px;
473
+ }
474
+ .info-row {
475
+ display: flex;
476
+ justify-content: space-between;
477
+ align-items: center;
478
+ padding: 9px 0;
479
+ border-bottom: 1px solid var(--border);
480
+ font-size: 13px;
481
+ }
482
+ .info-row:last-child { border-bottom: none; }
483
+ .info-k { color: var(--muted); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
484
+ .info-v { font-family: var(--mono); font-size: 11px; color: var(--text); }
485
+ .info-v.accent { color: var(--accent); }
486
+
487
+ /* HISTORY */
488
+ .hist-list { display: flex; flex-direction: column; gap: 0; }
489
+ .hist-item {
490
+ display: flex;
491
+ align-items: center;
492
+ gap: 13px;
493
+ padding: 12px 0;
494
+ border-bottom: 1px solid var(--border);
495
+ font-size: 12px;
496
+ }
497
+ .hist-item:last-child { border-bottom: none; }
498
+ .hist-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
499
+ .hist-ev { font-weight: 700; flex: 1; }
500
+ .hist-ts { font-size: 10px; color: var(--muted); font-family: var(--mono); }
501
+
502
+ /* EMPTY */
503
+ .empty { text-align: center; padding: 48px 20px; color: var(--muted); }
504
+ .empty-ico { font-size: 44px; margin-bottom: 12px; opacity: 0.25; }
505
+ .empty h3 { font-size: 16px; font-weight: 700; margin-bottom: 6px; }
506
+ .empty p { font-size: 12px; }
507
+
508
+ /* VIDEO MODAL */
509
+ .voverlay {
510
+ position: fixed;
511
+ inset: 0;
512
+ background: rgba(0,0,0,0.95);
513
+ display: none;
514
+ align-items: center;
515
+ justify-content: center;
516
+ z-index: 500;
517
+ backdrop-filter: blur(14px);
518
+ }
519
+ .voverlay.on { display: flex; }
520
+ .vwrap {
521
+ width: 94%;
522
+ max-width: 960px;
523
+ background: var(--surface);
524
+ border-radius: 18px;
525
+ overflow: hidden;
526
+ border: 1.5px solid var(--border);
527
+ box-shadow: var(--shadow-lg);
528
+ animation: popIn 0.25s ease both;
529
+ }
530
+ @keyframes popIn { from{opacity:0;transform:scale(0.94)} to{opacity:1;transform:scale(1)} }
531
+ .vhead {
532
+ padding: 14px 20px;
533
+ background: var(--panel);
534
+ border-bottom: 1px solid var(--border);
535
+ display: flex;
536
+ justify-content: space-between;
537
+ align-items: center;
538
+ }
539
+ .vinfo { font-size: 14px; font-weight: 700; }
540
+ .vinfo-sub { font-size: 10px; color: var(--muted); margin-top: 2px; }
541
+ #rv {
542
+ width: 100%;
543
+ max-height: 72vh;
544
+ background: #000;
545
+ display: block;
546
+ }
547
+ .v-status {
548
+ padding: 10px 20px;
549
+ background: var(--panel);
550
+ font-size: 11px;
551
+ color: var(--muted);
552
+ border-top: 1px solid var(--border);
553
+ display: flex;
554
+ align-items: center;
555
+ gap: 8px;
556
+ }
557
+ .v-controls {
558
+ padding: 10px 20px;
559
+ background: var(--panel2);
560
+ border-top: 1px solid var(--border);
561
+ display: flex;
562
+ gap: 8px;
563
+ align-items: center;
564
+ flex-wrap: wrap;
565
+ }
566
+ .close-btn {
567
+ width: 32px;
568
+ height: 32px;
569
+ border-radius: 50%;
570
+ background: var(--red-bg);
571
+ color: var(--red);
572
+ border: 1px solid rgba(220,38,38,0.2);
573
+ font-size: 14px;
574
+ cursor: pointer;
575
+ display: flex;
576
+ align-items: center;
577
+ justify-content: center;
578
+ transition: all 0.15s;
579
+ }
580
+ .close-btn:hover { background: var(--red); color: #fff; }
581
+
582
+ /* AUDIO MODAL */
583
+ .aoverlay {
584
+ position: fixed;
585
+ inset: 0;
586
+ background: rgba(0,0,0,0.92);
587
+ display: none;
588
+ align-items: center;
589
+ justify-content: center;
590
+ z-index: 510;
591
+ backdrop-filter: blur(12px);
592
+ }
593
+ .aoverlay.on { display: flex; }
594
+ .awrap {
595
+ width: 90%;
596
+ max-width: 420px;
597
+ background: var(--surface);
598
+ border-radius: 22px;
599
+ padding: 28px;
600
+ border: 1.5px solid var(--border);
601
+ box-shadow: var(--shadow-lg);
602
+ text-align: center;
603
+ animation: popIn 0.25s ease both;
604
+ }
605
+ .audio-anim {
606
+ display: flex;
607
+ align-items: flex-end;
608
+ justify-content: center;
609
+ gap: 4px;
610
+ height: 48px;
611
+ margin: 18px auto;
612
+ }
613
+ .abar {
614
+ width: 5px;
615
+ background: var(--accent);
616
+ border-radius: 3px;
617
+ animation: barPulse 1.1s ease-in-out infinite;
618
+ }
619
+ .abar:nth-child(2) { animation-delay: 0.15s; }
620
+ .abar:nth-child(3) { animation-delay: 0.3s; }
621
+ .abar:nth-child(4) { animation-delay: 0.15s; }
622
+ .abar:nth-child(5) { animation-delay: 0.0s; }
623
+ @keyframes barPulse {
624
+ 0%,100% { height: 8px; opacity: 0.4; }
625
+ 50% { height: 40px; opacity: 1; }
626
+ }
627
+ .audio-anim.inactive .abar { animation: none; height: 8px; opacity: 0.2; }
628
+ .audio-title { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
629
+ .audio-sub { font-size: 12px; color: var(--muted); }
630
+ #audio-status { font-size: 12px; color: var(--muted); margin-top: 10px; min-height: 18px; }
631
+
632
+ /* BUTTONS */
633
+ .btn {
634
+ padding: 8px 14px;
635
+ border: none;
636
+ border-radius: 8px;
637
+ font-size: 11px;
638
+ font-weight: 700;
639
+ cursor: pointer;
640
+ transition: all 0.15s;
641
+ text-transform: uppercase;
642
+ letter-spacing: 0.3px;
643
+ font-family: var(--font);
644
+ }
645
+ .btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #fff; }
646
+ .btn-primary:hover { filter: brightness(1.1); transform: translateY(-1px); }
647
+ .btn-danger { background: var(--red-bg); color: var(--red); border: 1px solid rgba(220,38,38,0.2); }
648
+ .btn-danger:hover { background: var(--red); color: #fff; }
649
+ .btn-neutral { background: var(--panel); color: var(--muted); border: 1px solid var(--border); }
650
+ .btn-neutral:hover { border-color: var(--accent); color: var(--accent); }
651
+
652
+ /* TOAST */
653
+ .toast {
654
+ position: fixed;
655
+ bottom: 20px;
656
+ left: 50%;
657
+ transform: translateX(-50%) translateY(60px);
658
+ background: var(--surface);
659
+ border: 1.5px solid var(--border);
660
+ color: var(--text);
661
+ padding: 11px 20px;
662
+ border-radius: 10px;
663
+ font-size: 12px;
664
+ font-weight: 600;
665
+ z-index: 9999;
666
+ opacity: 0;
667
+ transition: all 0.28s;
668
+ white-space: nowrap;
669
+ max-width: 90vw;
670
+ box-shadow: var(--shadow);
671
+ }
672
+ .toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
673
+ .toast.ok { border-color: rgba(22,163,74,0.35); color: var(--green); background: var(--green-bg); }
674
+ .toast.err { border-color: rgba(220,38,38,0.3); color: var(--red); background: var(--red-bg); }
675
+ .toast.info { border-color: rgba(37,99,235,0.3); color: var(--accent); background: var(--accent-glow); }
676
+
677
+ @media (max-width: 640px) {
678
+ .header { padding: 10px 14px; }
679
+ .content { padding: 14px; }
680
+ .action-grid { grid-template-columns: 1fr; }
681
+ .device-banner { padding: 12px 14px; }
682
+ }
683
+ </style>
684
+ </head>
685
+ <body>
686
+
687
+ <!-- THEME TOGGLE -->
688
+ <button class="theme-btn" id="theme-btn" title="Changer le thème" onclick="toggleTheme()">🌙</button>
689
+
690
+ <!-- ════════════ LOGIN SCREEN ════════════ -->
691
+ <div id="login-screen">
692
+ <div class="login-wrap">
693
+ <div class="login-brand">
694
+ <div class="login-icon">📱</div>
695
+ <div class="login-title">SecureView</div>
696
+ <div class="login-sub">Connectez-vous avec votre ID pour surveiller votre appareil.</div>
697
+ </div>
698
+
699
+ <div class="login-card">
700
+ <div class="field-row">
701
+ <label class="field-label" for="login-id">Votre Client ID</label>
702
+ <div class="input-wrap">
703
+ <span class="input-icon">🔑</span>
704
+ <input
705
+ class="text-input"
706
+ id="login-id"
707
+ type="text"
708
+ placeholder="Ex: SV_ABC123 ou MON_TELEPHONE"
709
+ autocomplete="off"
710
+ autocapitalize="characters"
711
+ spellcheck="false"
712
+ />
713
+ </div>
714
+ <div class="login-hint">L'ID vous a été fourni par l'administrateur lors de la création de votre compte.</div>
715
+ </div>
716
+
717
+ <button class="btn-login" id="login-btn" onclick="doLogin()">
718
+ <span id="login-btn-txt">🔓 Accéder à mon espace</span>
719
+ </button>
720
+
721
+ <div class="login-error" id="login-err"></div>
722
+ </div>
723
+ </div>
724
+ </div>
725
+
726
+ <!-- ════════════ APP ════════════ -->
727
+ <div id="app" style="display:none; flex-direction:column; min-height:100vh;">
728
+
729
+ <!-- HEADER -->
730
+ <div class="header">
731
+ <div class="header-left">
732
+ <div class="h-logo">📱</div>
733
+ <div class="h-name">SecureView</div>
734
+ <div class="h-badge">MON ESPACE</div>
735
+ </div>
736
+ <div class="header-right">
737
+ <div class="ws-pill off" id="ws-pill">
738
+ <div class="ws-dot"></div>
739
+ <span id="ws-txt">Déconnecté</span>
740
+ </div>
741
+ <button class="btn-logout" onclick="doLogout()">🔒 Déconnecter</button>
742
+ </div>
743
+ </div>
744
+
745
+ <!-- DEVICE BANNER -->
746
+ <div class="device-banner">
747
+ <div class="dev-avatar">📲</div>
748
+ <div class="dev-info">
749
+ <div class="dev-name" id="dev-name">—</div>
750
+ <div class="dev-id" id="dev-id">—</div>
751
+ </div>
752
+ <div class="dev-status-wrap">
753
+ <div class="status-pill offline" id="dev-status-pill">
754
+ <div class="status-dot"></div>
755
+ <span id="dev-status-txt">Hors ligne</span>
756
+ </div>
757
+ </div>
758
+ </div>
759
+
760
+ <!-- TABS -->
761
+ <div class="tabs">
762
+ <div class="tab active" onclick="showTab('dashboard')">🏠 Tableau de bord</div>
763
+ <div class="tab" onclick="showTab('info')">ℹ️ Infos</div>
764
+ <div class="tab" onclick="showTab('history')">📊 Historique</div>
765
+ </div>
766
+
767
+ <!-- CONTENT -->
768
+ <div class="content">
769
+
770
+ <!-- DASHBOARD TAB -->
771
+ <div class="tc active" id="tc-dashboard">
772
+ <div class="section-title">⚡ Actions disponibles</div>
773
+
774
+ <div class="action-grid">
775
+ <!-- SEE VIDEO -->
776
+ <div class="action-card blue disabled" id="card-watch" onclick="startWatch()">
777
+ <div class="ac-icon">🎥</div>
778
+ <div class="ac-title">Voir en direct</div>
779
+ <div class="ac-desc">Regardez le flux vidéo en temps réel de l'appareil lié à ce compte.</div>
780
+ <span class="ac-tag blue">VIDÉO LIVE</span>
781
+ </div>
782
+
783
+ <!-- LISTEN AUDIO -->
784
+ <div class="action-card green disabled" id="card-listen" onclick="startListen()">
785
+ <div class="ac-icon">🎧</div>
786
+ <div class="ac-title">Écouter en direct</div>
787
+ <div class="ac-desc">Écoutez uniquement l'audio ambiant de l'appareil, sans afficher la vidéo.</div>
788
+ <span class="ac-tag green">ÉCOUTE AUDIO</span>
789
+ </div>
790
+
791
+ <!-- CALL AUTO -->
792
+ <div class="action-card orange disabled" id="card-call-auto" onclick="callDevice('auto')">
793
+ <div class="ac-icon">📞</div>
794
+ <div class="ac-title">Appel automatique</div>
795
+ <div class="ac-desc">Déclenche automatiquement l'envoi du flux depuis l'appareil cible.</div>
796
+ <span class="ac-tag orange">AUTO</span>
797
+ </div>
798
+
799
+ <!-- CALL MANUAL -->
800
+ <div class="action-card blue disabled" id="card-call-manual" onclick="callDevice('manual')">
801
+ <div class="ac-icon">📲</div>
802
+ <div class="ac-title">Appel manuel</div>
803
+ <div class="ac-desc">Envoie un signal de déclenchement manuel vers l'appareil cible.</div>
804
+ <span class="ac-tag blue">MANUEL</span>
805
+ </div>
806
+ </div>
807
+
808
+ <div class="section-title" style="margin-top:8px;">🕐 Dernière activité</div>
809
+ <div class="info-card">
810
+ <div class="info-row">
811
+ <span class="info-k">Dernière vue</span>
812
+ <span class="info-v" id="d-lastseen">—</span>
813
+ </div>
814
+ <div class="info-row">
815
+ <span class="info-k">Statut actuel</span>
816
+ <span class="info-v" id="d-status">—</span>
817
+ </div>
818
+ <div class="info-row">
819
+ <span class="info-k">Dernière IP</span>
820
+ <span class="info-v" id="d-ip">—</span>
821
+ </div>
822
+ </div>
823
+ </div>
824
+
825
+ <!-- INFO TAB -->
826
+ <div class="tc" id="tc-info">
827
+ <div class="section-title">ℹ️ Informations du compte</div>
828
+ <div class="info-card">
829
+ <div class="info-row">
830
+ <span class="info-k">Nom</span>
831
+ <span class="info-v" id="i-name">—</span>
832
+ </div>
833
+ <div class="info-row">
834
+ <span class="info-k">Client ID</span>
835
+ <span class="info-v accent" id="i-id">—</span>
836
+ </div>
837
+ <div class="info-row">
838
+ <span class="info-k">Registration ID</span>
839
+ <span class="info-v" id="i-regid">—</span>
840
+ </div>
841
+ <div class="info-row">
842
+ <span class="info-k">Email</span>
843
+ <span class="info-v" id="i-email">—</span>
844
+ </div>
845
+ <div class="info-row">
846
+ <span class="info-k">Téléphone</span>
847
+ <span class="info-v" id="i-phone">—</span>
848
+ </div>
849
+ <div class="info-row">
850
+ <span class="info-k">Inscrit le</span>
851
+ <span class="info-v" id="i-created">—</span>
852
+ </div>
853
+ <div class="info-row">
854
+ <span class="info-k">Statut</span>
855
+ <span class="info-v" id="i-status">—</span>
856
+ </div>
857
+ </div>
858
+ </div>
859
+
860
+ <!-- HISTORY TAB -->
861
+ <div class="tc" id="tc-history">
862
+ <div class="section-title">📊 Historique des connexions</div>
863
+ <div class="info-card">
864
+ <div class="hist-list" id="hist-list">
865
+ <div class="empty">
866
+ <div class="empty-ico">📋</div>
867
+ <h3>Chargement…</h3>
868
+ </div>
869
+ </div>
870
+ </div>
871
+ </div>
872
+
873
+ </div><!-- /content -->
874
+ </div><!-- /app -->
875
+
876
+ <!-- ════════════ VIDEO MODAL ════════════ -->
877
+ <div class="voverlay" id="vov">
878
+ <div class="vwrap">
879
+ <div class="vhead">
880
+ <div>
881
+ <div class="vinfo">🎥 Surveillance en direct</div>
882
+ <div class="vinfo-sub" id="v-sub">En attente du flux…</div>
883
+ </div>
884
+ <button class="close-btn" onclick="closeVideo()" title="Fermer">✕</button>
885
+ </div>
886
+ <video id="rv" autoplay playsinline muted></video>
887
+ <div class="v-controls">
888
+ <button class="btn btn-neutral" onclick="toggleMute()" id="mute-btn">🔇 Son désactivé</button>
889
+ <button class="btn btn-danger" onclick="closeVideo()">⏹ Arrêter</button>
890
+ </div>
891
+ <div class="v-status" id="v-status">
892
+ <span id="v-status-txt">En attente de connexion…</span>
893
+ </div>
894
+ </div>
895
+ </div>
896
+
897
+ <!-- ════════════ AUDIO MODAL ════════════ -->
898
+ <div class="aoverlay" id="aov">
899
+ <div class="awrap">
900
+ <div style="font-size:38px;margin-bottom:8px;">🎧</div>
901
+ <div class="audio-title">Écoute en direct</div>
902
+ <div class="audio-sub" id="audio-name">Connexion à l'appareil…</div>
903
+ <div class="audio-anim inactive" id="audio-anim">
904
+ <div class="abar" style="height:18px"></div>
905
+ <div class="abar" style="height:28px"></div>
906
+ <div class="abar" style="height:36px"></div>
907
+ <div class="abar" style="height:28px"></div>
908
+ <div class="abar" style="height:18px"></div>
909
+ </div>
910
+ <audio id="ra" autoplay></audio>
911
+ <div id="audio-status">En attente du flux audio…</div>
912
+ <div style="margin-top:20px; display:flex; gap:10px; justify-content:center;">
913
+ <button class="btn btn-danger" onclick="closeAudio()">⏹ Arrêter l'écoute</button>
914
+ </div>
915
+ </div>
916
+ </div>
917
+
918
+ <!-- TOAST -->
919
+ <div class="toast" id="toast"></div>
920
+
921
+ <script>
922
+ /* ═══════════════════════════════════════
923
+ CONFIG
924
+ ═══════════════════════════════════════ */
925
+ const SERVER = 'https://tafita1206-admin.hf.space';
926
+ const API = SERVER + '/api';
927
+ const WS_URL = SERVER.replace('https://','wss://').replace('http://','ws://');
928
+
929
+ const RTC_CFG = {
930
+ iceServers: [
931
+ { urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] },
932
+ { urls: 'stun:stun.cloudflare.com:3478' },
933
+ ],
934
+ iceCandidatePoolSize: 10,
935
+ };
936
+
937
+ /* ══���════════════════════════════════════
938
+ THEME
939
+ ═══════════════════════════════════════ */
940
+ let darkMode = localStorage.getItem('sv_theme') === 'dark';
941
+ function applyTheme() {
942
+ document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
943
+ document.getElementById('theme-btn').textContent = darkMode ? '☀️' : '🌙';
944
+ }
945
+ function toggleTheme() {
946
+ darkMode = !darkMode;
947
+ localStorage.setItem('sv_theme', darkMode ? 'dark' : 'light');
948
+ applyTheme();
949
+ }
950
+ applyTheme();
951
+
952
+ /* ═══════════════════════════════════════
953
+ STATE
954
+ ═══════════════════════════════════════ */
955
+ let myClientId = null;
956
+ let myClientData = null;
957
+ let ws = null;
958
+ let adminSID = null; // l'admin session qui nous a envoyé l'offer
959
+ let pc = null;
960
+ let reconnT = null;
961
+ let retryDelay = 2000;
962
+ let mode = 'video'; // 'video' | 'audio'
963
+ let muted = true;
964
+
965
+ /* ═══════════════════════════════════════
966
+ LOGIN
967
+ ═══════════════════════════════════════ */
968
+ async function doLogin() {
969
+ const rawId = document.getElementById('login-id').value.trim();
970
+ if (!rawId) { showLoginErr('Veuillez saisir votre Client ID.'); return; }
971
+
972
+ const clientId = rawId.toUpperCase();
973
+ setBtnLoading(true);
974
+
975
+ try {
976
+ const res = await fetch(`${API}/clients/${clientId}`);
977
+ const data = await res.json();
978
+
979
+ if (!res.ok || !data.success) {
980
+ showLoginErr('ID introuvable. Vérifiez votre identifiant ou contactez l\'administrateur.');
981
+ setBtnLoading(false);
982
+ return;
983
+ }
984
+
985
+ // Connexion réussie
986
+ myClientId = clientId;
987
+ myClientData = data.data;
988
+ sessionStorage.setItem('sv_user_id', clientId);
989
+ sessionStorage.setItem('sv_user_data', JSON.stringify(data.data));
990
+ openApp();
991
+
992
+ } catch(e) {
993
+ showLoginErr('Erreur réseau. Veuillez réessayer.');
994
+ setBtnLoading(false);
995
+ }
996
+ }
997
+
998
+ function showLoginErr(msg) {
999
+ const el = document.getElementById('login-err');
1000
+ el.textContent = '❌ ' + msg;
1001
+ el.classList.add('show');
1002
+ document.getElementById('login-id').classList.add('error');
1003
+ setTimeout(() => { el.classList.remove('show'); document.getElementById('login-id').classList.remove('error'); }, 4000);
1004
+ }
1005
+
1006
+ function setBtnLoading(loading) {
1007
+ const btn = document.getElementById('login-btn');
1008
+ const txt = document.getElementById('login-btn-txt');
1009
+ btn.disabled = loading;
1010
+ txt.innerHTML = loading
1011
+ ? '<div class="spinner"></div> Connexion…'
1012
+ : '🔓 Accéder à mon espace';
1013
+ }
1014
+
1015
+ document.getElementById('login-id').addEventListener('keydown', e => {
1016
+ if (e.key === 'Enter') doLogin();
1017
+ });
1018
+
1019
+ // Restore session
1020
+ (function checkSession() {
1021
+ const savedId = sessionStorage.getItem('sv_user_id');
1022
+ const savedData = sessionStorage.getItem('sv_user_data');
1023
+ if (savedId && savedData) {
1024
+ myClientId = savedId;
1025
+ myClientData = JSON.parse(savedData);
1026
+ openApp();
1027
+ }
1028
+ })();
1029
+
1030
+ /* ═══════════════════════════════════════
1031
+ OPEN / CLOSE APP
1032
+ ═══════════════════════════════════════ */
1033
+ function openApp() {
1034
+ document.getElementById('login-screen').style.display = 'none';
1035
+ document.getElementById('app').style.display = 'flex';
1036
+ populateInfo();
1037
+ connectWS();
1038
+ loadHistory();
1039
+ }
1040
+
1041
+ function doLogout() {
1042
+ sessionStorage.removeItem('sv_user_id');
1043
+ sessionStorage.removeItem('sv_user_data');
1044
+ myClientId = null;
1045
+ myClientData = null;
1046
+ if (ws) { try { ws.close(); } catch(e) {} ws = null; }
1047
+ clearTimeout(reconnT);
1048
+ document.getElementById('app').style.display = 'none';
1049
+ document.getElementById('login-screen').style.display = 'flex';
1050
+ document.getElementById('login-id').value = '';
1051
+ }
1052
+
1053
+ /* ═══════════════════════════════════════
1054
+ POPULATE UI
1055
+ ═══════════════════════════════════════ */
1056
+ function populateInfo() {
1057
+ const c = myClientData;
1058
+ if (!c) return;
1059
+ document.getElementById('dev-name').textContent = c.name || '—';
1060
+ document.getElementById('dev-id').textContent = '🔑 ' + c.client_id;
1061
+ document.getElementById('d-lastseen').textContent = ago(c.last_seen);
1062
+ document.getElementById('d-status').textContent = c.status === 'online' ? '🟢 En ligne' : '⚫ Hors ligne';
1063
+ document.getElementById('d-ip').textContent = c.last_ip || '—';
1064
+ document.getElementById('i-name').textContent = c.name || '—';
1065
+ document.getElementById('i-id').textContent = c.client_id;
1066
+ document.getElementById('i-regid').textContent = c.registration_id || '—';
1067
+ document.getElementById('i-email').textContent = c.email || '—';
1068
+ document.getElementById('i-phone').textContent = c.phone || '—';
1069
+ document.getElementById('i-created').textContent = fmt(c.created_at);
1070
+ document.getElementById('i-status').textContent = c.status === 'online' ? '🟢 En ligne' : '⚫ Hors ligne';
1071
+ updateDeviceStatus(c.isOnline || c.status === 'online');
1072
+ }
1073
+
1074
+ function updateDeviceStatus(online) {
1075
+ const pill = document.getElementById('dev-status-pill');
1076
+ const txt = document.getElementById('dev-status-txt');
1077
+ pill.className = 'status-pill ' + (online ? 'online' : 'offline');
1078
+ txt.textContent = online ? 'En ligne' : 'Hors ligne';
1079
+
1080
+ // Enable/disable action cards
1081
+ const ids = ['card-watch', 'card-listen', 'card-call-auto', 'card-call-manual'];
1082
+ ids.forEach(id => {
1083
+ const el = document.getElementById(id);
1084
+ if (online) el.classList.remove('disabled');
1085
+ else el.classList.add('disabled');
1086
+ });
1087
+
1088
+ // Update dashboard info
1089
+ if (myClientData) {
1090
+ document.getElementById('d-status').textContent = online ? '🟢 En ligne' : '⚫ Hors ligne';
1091
+ }
1092
+ }
1093
+
1094
+ /* ═══════════════════════════════════════
1095
+ WEBSOCKET — client qui s'enregistre
1096
+ ═══════════════════════════════════════ */
1097
+ function connectWS() {
1098
+ clearTimeout(reconnT);
1099
+ if (ws && ws.readyState <= WebSocket.OPEN) { try { ws.close(); } catch(e) {} }
1100
+
1101
+ try {
1102
+ ws = new WebSocket(WS_URL);
1103
+
1104
+ ws.onopen = () => {
1105
+ retryDelay = 2000;
1106
+ setWsUI(true);
1107
+ ws.send(JSON.stringify({ type: 'register_client', clientId: myClientId }));
1108
+ };
1109
+
1110
+ ws.onmessage = (ev) => {
1111
+ let d;
1112
+ try { d = JSON.parse(ev.data); } catch(e) { return; }
1113
+ handleMsg(d);
1114
+ };
1115
+
1116
+ ws.onerror = () => {};
1117
+
1118
+ ws.onclose = () => {
1119
+ setWsUI(false);
1120
+ adminSID = null;
1121
+ reconnT = setTimeout(connectWS, retryDelay);
1122
+ retryDelay = Math.min(retryDelay * 1.5, 30000);
1123
+ };
1124
+
1125
+ } catch(e) {
1126
+ reconnT = setTimeout(connectWS, retryDelay);
1127
+ }
1128
+ }
1129
+
1130
+ function wsSend(data) {
1131
+ try { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data)); } catch(e) {}
1132
+ }
1133
+
1134
+ /* ═══════════════════════════════════════
1135
+ MESSAGES SERVEUR
1136
+ ═══════════════════════════════════════ */
1137
+ async function handleMsg(d) {
1138
+ switch (d.type) {
1139
+
1140
+ case 'registration_success':
1141
+ console.log('[WS] Enregistré comme client:', d.clientId);
1142
+ // Refresh data
1143
+ try {
1144
+ const res = await fetch(`${API}/clients/${myClientId}`);
1145
+ const data = await res.json();
1146
+ if (data.success) {
1147
+ myClientData = data.data;
1148
+ sessionStorage.setItem('sv_user_data', JSON.stringify(data.data));
1149
+ populateInfo();
1150
+ }
1151
+ } catch(e) {}
1152
+ break;
1153
+
1154
+ case 'error':
1155
+ toast('⚠️ ' + (d.message || 'Erreur serveur'), 'err');
1156
+ break;
1157
+
1158
+ // ── L'admin nous envoie un appel entrant ──
1159
+ case 'incoming_call': {
1160
+ adminSID = d.adminSID;
1161
+ const m = d.mode || 'manual';
1162
+ toast(`📞 Appel ${m} reçu`, 'info');
1163
+ break;
1164
+ }
1165
+
1166
+ // ── L'admin nous envoie une offre WebRTC ──
1167
+ case 'webrtc_offer': {
1168
+ adminSID = d.adminSID;
1169
+ await handleWebRTCOffer(d.offer, d.adminSID);
1170
+ break;
1171
+ }
1172
+
1173
+ // ── ICE candidates de l'admin ──
1174
+ case 'webrtc_ice':
1175
+ if (pc && d.candidate) {
1176
+ try { await pc.addIceCandidate(new RTCIceCandidate(d.candidate)); } catch(e) {}
1177
+ }
1178
+ break;
1179
+
1180
+ case 'webrtc_end':
1181
+ toast('Flux terminé par le superviseur', 'info');
1182
+ closeVideo();
1183
+ closeAudio();
1184
+ break;
1185
+ }
1186
+ }
1187
+
1188
+ /* ═══════════════════════════════════════
1189
+ WEBRTC — CÔTÉ CLIENT (ÉMETTEUR)
1190
+ ═══════════════════════════════════════ */
1191
+ async function handleWebRTCOffer(offer, fromAdminSID) {
1192
+ closePC();
1193
+ pc = new RTCPeerConnection(RTC_CFG);
1194
+
1195
+ // Obtenir le flux selon le mode actif
1196
+ let stream;
1197
+ try {
1198
+ const constraints = mode === 'audio'
1199
+ ? { audio: true, video: false }
1200
+ : { video: { facingMode: 'environment' }, audio: true };
1201
+ stream = await navigator.mediaDevices.getUserMedia(constraints);
1202
+ } catch(e) {
1203
+ toast('⚠️ Accès caméra/micro refusé: ' + e.message, 'err');
1204
+ return;
1205
+ }
1206
+
1207
+ // Ajouter les tracks au PeerConnection
1208
+ stream.getTracks().forEach(track => pc.addTrack(track, stream));
1209
+
1210
+ // Nos ICE candidates → serveur → admin
1211
+ pc.onicecandidate = ({ candidate }) => {
1212
+ if (!candidate) return;
1213
+ wsSend({ type: 'webrtc_ice', direction: 'to_admin', targetId: fromAdminSID, candidate });
1214
+ };
1215
+
1216
+ pc.oniceconnectionstatechange = () => {
1217
+ const s = pc?.iceConnectionState;
1218
+ if (s === 'connected' || s === 'completed') {
1219
+ toast('🎥 Flux actif', 'ok');
1220
+ }
1221
+ if (s === 'disconnected' || s === 'failed') closePC();
1222
+ };
1223
+
1224
+ // Appliquer l'offer et répondre
1225
+ try {
1226
+ await pc.setRemoteDescription(new RTCSessionDescription(offer));
1227
+ const answer = await pc.createAnswer();
1228
+ await pc.setLocalDescription(answer);
1229
+ wsSend({ type: 'webrtc_answer', targetAdminSID: fromAdminSID, answer });
1230
+ } catch(e) {
1231
+ console.error('[RTC] Erreur answer:', e);
1232
+ toast('Erreur WebRTC: ' + e.message, 'err');
1233
+ closePC();
1234
+ }
1235
+ }
1236
+
1237
+ function closePC() {
1238
+ if (pc) { try { pc.close(); } catch(e) {} pc = null; }
1239
+ }
1240
+
1241
+ /* ═══════════════════════════════════════
1242
+ ACTIONS UTILISATEUR
1243
+ ═══════════════════════════════════════ */
1244
+ async function startWatch() {
1245
+ mode = 'video';
1246
+ document.getElementById('vov').classList.add('on');
1247
+ setVStatus('En attente de l\'administrateur…');
1248
+ // Envoie un signal au serveur pour notifier qu'on est prêt
1249
+ try {
1250
+ const res = await fetch(`${API}/clients/${myClientId}/call`, {
1251
+ method: 'POST',
1252
+ headers: { 'Content-Type': 'application/json' },
1253
+ body: JSON.stringify({ mode: 'manual' })
1254
+ });
1255
+ // On va aussi déclencher une connexion WebRTC directe si on peut
1256
+ // En pratique la caméra sera activée quand l'admin envoie un offer
1257
+ setVStatus('Signal envoyé — en attente de l\'offer WebRTC…');
1258
+ } catch(e) {
1259
+ setVStatus('Prêt — en attente de connexion de l\'administrateur…');
1260
+ }
1261
+ toast('🎥 Interface de surveillance ouverte', 'info');
1262
+ }
1263
+
1264
+ async function startListen() {
1265
+ mode = 'audio';
1266
+ document.getElementById('aov').classList.add('on');
1267
+ document.getElementById('audio-name').textContent = myClientData?.name || '—';
1268
+ document.getElementById('audio-status').textContent = 'En attente de connexion…';
1269
+ document.getElementById('audio-anim').classList.add('inactive');
1270
+
1271
+ // Essaie d'activer le micro localement pour pré-test
1272
+ try {
1273
+ await navigator.mediaDevices.getUserMedia({ audio: true });
1274
+ document.getElementById('audio-status').textContent = 'Micro disponible — en attente du flux entrant…';
1275
+ } catch(e) {
1276
+ document.getElementById('audio-status').textContent = '⚠️ Accès micro refusé: ' + e.message;
1277
+ }
1278
+
1279
+ toast('🎧 Mode écoute activé', 'ok');
1280
+ }
1281
+
1282
+ async function callDevice(callMode) {
1283
+ try {
1284
+ const res = await fetch(`${API}/clients/${myClientId}/call`, {
1285
+ method: 'POST',
1286
+ headers: { 'Content-Type': 'application/json' },
1287
+ body: JSON.stringify({ mode: callMode })
1288
+ });
1289
+ const data = await res.json();
1290
+ if (data.success) toast(`📞 Appel ${callMode} envoyé`, 'ok');
1291
+ else toast(data.error || 'Erreur', 'err');
1292
+ } catch(e) {
1293
+ toast('Erreur réseau', 'err');
1294
+ }
1295
+ }
1296
+
1297
+ /* ═══════════════════════════════════════
1298
+ VIDEO CONTROLS
1299
+ ═══════════════════════════════════════ */
1300
+ function toggleMute() {
1301
+ const video = document.getElementById('rv');
1302
+ const btn = document.getElementById('mute-btn');
1303
+ muted = !muted;
1304
+ video.muted = muted;
1305
+ btn.textContent = muted ? '🔇 Son désactivé' : '🔊 Son activé';
1306
+ }
1307
+
1308
+ function closeVideo() {
1309
+ closePC();
1310
+ const v = document.getElementById('rv');
1311
+ if (v) v.srcObject = null;
1312
+ document.getElementById('vov').classList.remove('on');
1313
+ }
1314
+
1315
+ function setVStatus(msg) {
1316
+ const el = document.getElementById('v-status-txt');
1317
+ if (el) el.textContent = msg;
1318
+ const sub = document.getElementById('v-sub');
1319
+ if (sub) sub.textContent = msg;
1320
+ }
1321
+
1322
+ /* ═══════════════════════════════════════
1323
+ AUDIO CONTROLS
1324
+ ═══════════════════════════════════════ */
1325
+ function closeAudio() {
1326
+ closePC();
1327
+ const a = document.getElementById('ra');
1328
+ if (a) a.srcObject = null;
1329
+ document.getElementById('aov').classList.remove('on');
1330
+ document.getElementById('audio-anim').classList.add('inactive');
1331
+ }
1332
+
1333
+ function activateAudioAnim() {
1334
+ document.getElementById('audio-anim').classList.remove('inactive');
1335
+ document.getElementById('audio-status').textContent = '🎧 Écoute active';
1336
+ }
1337
+
1338
+ /* ═══════════════════════════════════════
1339
+ HISTORY
1340
+ ═══════════════════════════════════════ */
1341
+ async function loadHistory() {
1342
+ try {
1343
+ const res = await fetch(`${API}/clients/${myClientId}/history`);
1344
+ const data = await res.json();
1345
+ const evs = data.data || [];
1346
+ const list = document.getElementById('hist-list');
1347
+ const cols = { connect: 'var(--green)', disconnect: 'var(--red)', register: 'var(--accent)' };
1348
+ list.innerHTML = evs.length
1349
+ ? evs.map(e => `
1350
+ <div class="hist-item">
1351
+ <div class="hist-dot" style="background:${cols[e.event_type] || 'var(--muted)'}"></div>
1352
+ <div class="hist-ev">${e.event_type}</div>
1353
+ <div class="hist-ts">${fmt(e.timestamp)} ${new Date(e.timestamp).toLocaleTimeString('fr-FR')}</div>
1354
+ </div>
1355
+ `).join('')
1356
+ : '<div class="empty"><div class="empty-ico">📋</div><h3>Aucun historique</h3></div>';
1357
+ } catch(e) {
1358
+ document.getElementById('hist-list').innerHTML = '<div class="empty"><div class="empty-ico">⚠️</div><h3>Erreur de chargement</h3></div>';
1359
+ }
1360
+ }
1361
+
1362
+ /* ═══════════════════════════════════════
1363
+ TABS
1364
+ ═══════════════════════════════════════ */
1365
+ function showTab(n) {
1366
+ const tabs = ['dashboard', 'info', 'history'];
1367
+ tabs.forEach((t, i) => {
1368
+ document.querySelectorAll('.tab')[i].classList.toggle('active', t === n);
1369
+ document.getElementById(`tc-${t}`).classList.toggle('active', t === n);
1370
+ });
1371
+ if (n === 'history') loadHistory();
1372
+ }
1373
+
1374
+ /* ═══════════════════════════════════════
1375
+ WS UI
1376
+ ═══════════════════════════════════════ */
1377
+ function setWsUI(on) {
1378
+ const pill = document.getElementById('ws-pill');
1379
+ const txt = document.getElementById('ws-txt');
1380
+ pill.className = 'ws-pill ' + (on ? 'on' : 'off');
1381
+ txt.textContent = on ? 'Connecté' : 'Déconnecté';
1382
+ }
1383
+
1384
+ /* ═══════════════════════════════════════
1385
+ UTILS
1386
+ ═══════════════════════════════════════ */
1387
+ let toastT = null;
1388
+ function toast(msg, type = 'info') {
1389
+ const el = document.getElementById('toast');
1390
+ el.textContent = msg;
1391
+ el.className = 'toast ' + type + ' on';
1392
+ clearTimeout(toastT);
1393
+ toastT = setTimeout(() => el.classList.remove('on'), 3500);
1394
+ }
1395
+
1396
+ function ago(ds) {
1397
+ if (!ds) return 'jamais';
1398
+ const s = Math.floor((Date.now() - new Date(ds)) / 1000);
1399
+ if (s < 60) return `il y a ${s}s`;
1400
+ if (s < 3600) return `il y a ${Math.floor(s/60)}min`;
1401
+ if (s < 86400) return `il y a ${Math.floor(s/3600)}h`;
1402
+ return `il y a ${Math.floor(s/86400)}j`;
1403
+ }
1404
+
1405
+ function fmt(ds) {
1406
+ if (!ds) return 'N/A';
1407
+ return new Date(ds).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
1408
+ }
1409
+ </script>
1410
+ </body>
1411
+ </html>
public/user_client.html DELETED
@@ -1,1260 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>SecureView — Espace Client</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
9
- <style>
10
- /* ══════════════════════════════════════════
11
- TOKENS — LIGHT (default) & DARK
12
- ══════════════════════════════════════════ */
13
- :root {
14
- --bg: #f0f4fa;
15
- --surface: #ffffff;
16
- --panel: #f7f9fc;
17
- --border: #dde3ee;
18
- --shadow: rgba(30,50,110,.09);
19
- --text: #1a2340;
20
- --sub: #6b7a99;
21
- --muted: #a0adc0;
22
- --accent: #3d6eff;
23
- --accent2: #1a44cc;
24
- --green: #16a34a;
25
- --red: #dc2626;
26
- --orange: #d97706;
27
- --gold: #b08520;
28
- --gold-bg: rgba(176,133,32,.08);
29
- --r: 14px;
30
- --font: 'Sora', sans-serif;
31
- --mono: 'JetBrains Mono', monospace;
32
- --trans: .22s cubic-bezier(.4,0,.2,1);
33
- }
34
- [data-theme="dark"] {
35
- --bg: #080c14;
36
- --surface: #0e1420;
37
- --panel: #131b28;
38
- --border: #1e2d45;
39
- --shadow: rgba(0,0,0,.45);
40
- --text: #c8d6ef;
41
- --sub: #5a6e8a;
42
- --muted: #374a63;
43
- --accent: #4d7bff;
44
- --accent2: #2a55e0;
45
- --green: #22c55e;
46
- --red: #ef4444;
47
- --orange: #f59e0b;
48
- --gold: #c9a84c;
49
- --gold-bg: rgba(201,168,76,.07);
50
- }
51
-
52
- *, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
53
- html, body {
54
- height:100%; background:var(--bg); color:var(--text);
55
- font-family:var(--font); font-size:14px; overflow-x:hidden;
56
- transition: background var(--trans), color var(--trans);
57
- }
58
-
59
- /* ══════════════════════════════════════════
60
- THEME TOGGLE BUTTON
61
- ══════════════════════════════════════════ */
62
- .theme-btn {
63
- position: fixed; top:18px; right:20px; z-index:10000;
64
- width:42px; height:42px; border-radius:50%;
65
- background:var(--surface); border:1.5px solid var(--border);
66
- box-shadow:0 2px 12px var(--shadow);
67
- cursor:pointer; display:flex; align-items:center; justify-content:center;
68
- font-size:18px; transition:all var(--trans);
69
- }
70
- .theme-btn:hover { transform:scale(1.08); box-shadow:0 4px 18px var(--shadow); }
71
-
72
- /* ══════════════════════════════════════════
73
- LOGIN SCREEN
74
- ══════════════════════════════════════════ */
75
- #login-screen {
76
- position:fixed; inset:0; display:flex; align-items:center;
77
- justify-content:center; z-index:9000;
78
- background: radial-gradient(ellipse 70% 60% at 30% 40%, rgba(61,110,255,.07), transparent 60%),
79
- radial-gradient(ellipse 50% 50% at 75% 80%, rgba(16,163,74,.05), transparent 55%),
80
- var(--bg);
81
- }
82
- .login-wrap {
83
- width:100%; max-width:420px; padding:20px;
84
- animation: fadeUp .55s ease both;
85
- }
86
- @keyframes fadeUp {
87
- from { opacity:0; transform:translateY(24px); }
88
- to { opacity:1; transform:translateY(0); }
89
- }
90
- .login-card {
91
- background:var(--surface);
92
- border:1.5px solid var(--border);
93
- border-radius:24px;
94
- padding:44px 40px 40px;
95
- box-shadow:0 8px 40px var(--shadow), 0 1px 0 rgba(255,255,255,.6) inset;
96
- }
97
- [data-theme="dark"] .login-card {
98
- box-shadow:0 8px 40px var(--shadow);
99
- }
100
- .login-brand {
101
- display:flex; align-items:center; gap:12px; margin-bottom:32px;
102
- }
103
- .brand-icon {
104
- width:48px; height:48px; border-radius:13px;
105
- background:linear-gradient(135deg, var(--accent), var(--accent2));
106
- display:flex; align-items:center; justify-content:center;
107
- font-size:22px; box-shadow:0 4px 14px rgba(61,110,255,.35);
108
- flex-shrink:0;
109
- }
110
- .brand-text { line-height:1.2; }
111
- .brand-name {
112
- font-size:19px; font-weight:800; color:var(--text);
113
- letter-spacing:-.4px;
114
- }
115
- .brand-sub {
116
- font-size:11px; color:var(--muted); font-weight:500;
117
- text-transform:uppercase; letter-spacing:.8px; margin-top:2px;
118
- }
119
-
120
- .login-title { font-size:22px; font-weight:800; margin-bottom:6px; letter-spacing:-.5px; }
121
- .login-desc { font-size:13px; color:var(--sub); margin-bottom:28px; line-height:1.55; }
122
-
123
- .field { margin-bottom:16px; }
124
- .field label {
125
- display:block; font-size:11px; font-weight:700;
126
- text-transform:uppercase; letter-spacing:.8px;
127
- color:var(--sub); margin-bottom:7px;
128
- }
129
- .field input {
130
- width:100%; padding:12px 15px;
131
- background:var(--panel); border:1.5px solid var(--border);
132
- border-radius:10px; color:var(--text);
133
- font-family:var(--font); font-size:14px;
134
- transition:all var(--trans); outline:none;
135
- }
136
- .field input:focus { border-color:var(--accent); background:var(--surface);
137
- box-shadow:0 0 0 3px rgba(61,110,255,.1); }
138
- .field input::placeholder { color:var(--muted); }
139
-
140
- .login-btn {
141
- width:100%; padding:14px; border:none; border-radius:11px;
142
- background:linear-gradient(135deg, var(--accent), var(--accent2));
143
- color:#fff; font-family:var(--font); font-size:14px; font-weight:700;
144
- cursor:pointer; letter-spacing:.2px;
145
- box-shadow:0 4px 16px rgba(61,110,255,.35);
146
- transition:all var(--trans); margin-top:6px;
147
- }
148
- .login-btn:hover { filter:brightness(1.08); transform:translateY(-1px);
149
- box-shadow:0 6px 22px rgba(61,110,255,.4); }
150
- .login-btn:active { transform:translateY(0); }
151
- .login-btn:disabled { opacity:.5; cursor:not-allowed; transform:none; filter:none; }
152
-
153
- .login-err {
154
- margin-top:14px; padding:10px 13px; border-radius:8px;
155
- background:rgba(220,38,38,.06); border:1px solid rgba(220,38,38,.2);
156
- color:var(--red); font-size:12px; font-weight:600; display:none;
157
- }
158
- .login-err.show { display:block; animation:fadeUp .25s ease; }
159
- .login-note {
160
- text-align:center; margin-top:20px; font-size:11px; color:var(--muted);
161
- line-height:1.5;
162
- }
163
-
164
- /* ══════════════════════════════════════════
165
- MAIN APP (hidden by default)
166
- ══════════════════════════════════════════ */
167
- #app { display:none; min-height:100vh; flex-direction:column; }
168
-
169
- /* HEADER */
170
- .header {
171
- background:var(--surface); border-bottom:1.5px solid var(--border);
172
- padding:14px 28px; display:flex; align-items:center;
173
- justify-content:space-between; position:sticky; top:0; z-index:100;
174
- box-shadow:0 2px 16px var(--shadow);
175
- }
176
- .h-left { display:flex; align-items:center; gap:13px; }
177
- .h-logo {
178
- width:38px; height:38px; border-radius:10px;
179
- background:linear-gradient(135deg, var(--accent), var(--accent2));
180
- display:flex; align-items:center; justify-content:center;
181
- font-size:18px; box-shadow:0 3px 10px rgba(61,110,255,.3);
182
- }
183
- .h-title { font-size:16px; font-weight:800; letter-spacing:-.3px; }
184
- .h-right { display:flex; align-items:center; gap:12px; }
185
- .ws-pill {
186
- display:flex; align-items:center; gap:6px; font-size:11px;
187
- padding:5px 12px; border-radius:20px; border:1.5px solid;
188
- font-weight:600;
189
- }
190
- .ws-pill.on { color:var(--green); border-color:rgba(22,163,74,.3); background:rgba(22,163,74,.06); }
191
- .ws-pill.off { color:var(--red); border-color:rgba(220,38,38,.25); background:rgba(220,38,38,.04); }
192
- .ws-dot { width:6px; height:6px; border-radius:50%; background:currentColor; animation:pulse 1.8s infinite; }
193
- @keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.3;} }
194
- .btn-logout {
195
- background:rgba(220,38,38,.07); color:var(--red);
196
- border:1.5px solid rgba(220,38,38,.2); padding:7px 14px;
197
- border-radius:8px; font-size:11px; font-weight:700;
198
- cursor:pointer; font-family:var(--font); transition:all var(--trans);
199
- }
200
- .btn-logout:hover { background:rgba(220,38,38,.14); }
201
-
202
- /* TABS */
203
- .tabs {
204
- display:flex; padding:0 28px; background:var(--surface);
205
- border-bottom:1.5px solid var(--border); overflow-x:auto; gap:4px;
206
- }
207
- .tab {
208
- padding:13px 18px; font-size:11px; font-weight:700; cursor:pointer;
209
- color:var(--sub); border-bottom:3px solid transparent;
210
- transition:all var(--trans); text-transform:uppercase;
211
- letter-spacing:.7px; white-space:nowrap;
212
- }
213
- .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
214
- .tab:hover:not(.active) { color:var(--text); }
215
-
216
- /* CONTENT */
217
- .content { flex:1; max-width:1100px; margin:0 auto; padding:28px; width:100%; }
218
- .pane { display:none; animation:fadeUp .3s ease; }
219
- .pane.active { display:block; }
220
-
221
- /* ══ PROFILE PANE ══ */
222
- .profile-grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; margin-bottom:22px; }
223
- @media(max-width:640px) { .profile-grid { grid-template-columns:1fr; } }
224
-
225
- .info-card {
226
- background:var(--surface); border:1.5px solid var(--border);
227
- border-radius:16px; padding:22px;
228
- box-shadow:0 2px 12px var(--shadow);
229
- }
230
- .info-card-title {
231
- font-size:11px; font-weight:700; color:var(--sub);
232
- text-transform:uppercase; letter-spacing:.8px;
233
- margin-bottom:16px; display:flex; align-items:center; gap:7px;
234
- }
235
- .info-row {
236
- display:flex; justify-content:space-between; align-items:center;
237
- padding:9px 0; border-bottom:1px solid var(--border); font-size:13px;
238
- }
239
- .info-row:last-child { border-bottom:none; }
240
- .info-key { color:var(--sub); font-weight:500; }
241
- .info-val { font-weight:600; color:var(--text); text-align:right; max-width:60%; word-break:break-all; }
242
- .info-val.mono { font-family:var(--mono); font-size:11px; color:var(--accent);
243
- background:rgba(61,110,255,.06); padding:3px 8px; border-radius:5px; cursor:pointer; }
244
- .info-val.mono:hover { background:rgba(61,110,255,.12); }
245
- .status-badge {
246
- display:inline-flex; align-items:center; gap:5px;
247
- padding:3px 10px; border-radius:20px; font-size:11px; font-weight:700;
248
- }
249
- .status-badge.online { background:rgba(22,163,74,.1); color:var(--green); border:1px solid rgba(22,163,74,.25); }
250
- .status-badge.offline { background:rgba(100,116,139,.08); color:var(--sub); border:1px solid var(--border); }
251
-
252
- /* ══ LIVE / WATCH PANE ══ */
253
- .watch-header {
254
- display:flex; align-items:center; justify-content:space-between;
255
- margin-bottom:22px; flex-wrap:wrap; gap:12px;
256
- }
257
- .section-label {
258
- font-size:12px; font-weight:700; color:var(--sub);
259
- text-transform:uppercase; letter-spacing:.8px;
260
- display:flex; align-items:center; gap:8px;
261
- }
262
- .dot-live { width:8px; height:8px; border-radius:50%; background:var(--red);
263
- animation:pulse 1.2s infinite; box-shadow:0 0 6px rgba(220,38,38,.5); }
264
-
265
- .watch-card {
266
- background:var(--surface); border:1.5px solid var(--border);
267
- border-radius:16px; overflow:hidden;
268
- box-shadow:0 4px 20px var(--shadow);
269
- }
270
- .watch-card-head {
271
- padding:14px 20px; background:var(--panel);
272
- border-bottom:1.5px solid var(--border);
273
- display:flex; align-items:center; justify-content:space-between;
274
- }
275
- .wch-name { font-size:14px; font-weight:700; }
276
- .wch-id { font-size:10px; color:var(--muted); font-family:var(--mono); margin-top:2px; }
277
-
278
- /* Actions */
279
- .watch-actions {
280
- display:flex; gap:10px; padding:16px 20px;
281
- border-bottom:1.5px solid var(--border); flex-wrap:wrap;
282
- }
283
- .wbtn {
284
- padding:9px 16px; border-radius:9px; border:none;
285
- font-family:var(--font); font-size:12px; font-weight:700;
286
- cursor:pointer; transition:all var(--trans); display:flex;
287
- align-items:center; gap:6px; letter-spacing:.2px;
288
- }
289
- .wbtn:disabled { opacity:.35; cursor:not-allowed; }
290
- .wbtn-watch {
291
- background:linear-gradient(135deg, var(--accent), var(--accent2));
292
- color:#fff; box-shadow:0 3px 12px rgba(61,110,255,.3);
293
- }
294
- .wbtn-watch:hover:not(:disabled) { filter:brightness(1.1); transform:translateY(-1px); }
295
- .wbtn-listen {
296
- background:rgba(22,163,74,.09); color:var(--green);
297
- border:1.5px solid rgba(22,163,74,.25);
298
- }
299
- .wbtn-listen:hover:not(:disabled) { background:rgba(22,163,74,.18); }
300
- .wbtn-listen.active-listen {
301
- background:rgba(22,163,74,.18); border-color:var(--green);
302
- animation:listenPulse 2s infinite;
303
- }
304
- @keyframes listenPulse {
305
- 0%,100% { box-shadow:0 0 0 0 rgba(22,163,74,.4); }
306
- 50% { box-shadow:0 0 0 6px rgba(22,163,74,0); }
307
- }
308
- .wbtn-call-auto { background:rgba(61,110,255,.08); color:var(--accent); border:1.5px solid rgba(61,110,255,.2); }
309
- .wbtn-call-auto:hover:not(:disabled) { background:rgba(61,110,255,.16); }
310
- .wbtn-call-man { background:rgba(217,119,6,.08); color:var(--orange); border:1.5px solid rgba(217,119,6,.2); }
311
- .wbtn-call-man:hover:not(:disabled) { background:rgba(217,119,6,.16); }
312
- .wbtn-hist { background:var(--panel); color:var(--sub); border:1.5px solid var(--border); }
313
- .wbtn-hist:hover { border-color:var(--accent); color:var(--accent); }
314
-
315
- /* Video area */
316
- .video-area {
317
- position:relative; background:#000; min-height:340px;
318
- display:flex; align-items:center; justify-content:center;
319
- overflow:hidden;
320
- }
321
- #user-video { width:100%; max-height:70vh; display:none; object-fit:cover; }
322
- #user-video.front { transform:scaleX(-1); } /* mirror pour frontale */
323
-
324
- /* Flip camera button — overlay sur la vidéo */
325
- .btn-flip {
326
- position:absolute; bottom:14px; right:14px; z-index:10;
327
- width:44px; height:44px; border-radius:50%;
328
- background:rgba(0,0,0,.55); border:1.5px solid rgba(255,255,255,.25);
329
- color:#fff; font-size:20px; cursor:pointer;
330
- display:none; align-items:center; justify-content:center;
331
- backdrop-filter:blur(6px);
332
- transition:all var(--trans);
333
- box-shadow:0 2px 12px rgba(0,0,0,.4);
334
- }
335
- .btn-flip:hover { background:rgba(0,0,0,.75); transform:scale(1.08); }
336
- .btn-flip.show { display:flex; }
337
- .btn-flip.flipping { animation:flipSpin .4s ease; }
338
- @keyframes flipSpin {
339
- 0% { transform:rotate(0deg) scale(1); }
340
- 50% { transform:rotate(180deg) scale(.85); }
341
- 100% { transform:rotate(360deg) scale(1); }
342
- }
343
-
344
- /* Camera label badge */
345
- .cam-label {
346
- position:absolute; top:12px; left:12px; z-index:10;
347
- background:rgba(0,0,0,.5); backdrop-filter:blur(6px);
348
- border:1px solid rgba(255,255,255,.15); border-radius:20px;
349
- padding:4px 11px; font-size:11px; font-weight:700;
350
- color:#fff; display:none; align-items:center; gap:5px;
351
- letter-spacing:.4px;
352
- }
353
- .cam-label.show { display:flex; }
354
-
355
- .video-placeholder {
356
- text-align:center; padding:50px 30px; color:var(--sub);
357
- }
358
- .video-placeholder .icon { font-size:48px; margin-bottom:12px; opacity:.25; }
359
- .video-placeholder p { font-size:13px; line-height:1.6; }
360
-
361
- /* Audio area */
362
- .audio-area {
363
- padding:16px 20px; background:var(--panel);
364
- border-top:1.5px solid var(--border); display:none;
365
- }
366
- .audio-area.show { display:block; }
367
- .audio-viz {
368
- display:flex; align-items:flex-end; gap:3px; height:36px;
369
- justify-content:center; margin-bottom:8px;
370
- }
371
- .av-bar {
372
- width:4px; border-radius:2px;
373
- background:linear-gradient(to top, var(--green), rgba(22,163,74,.3));
374
- animation:vizAnim 1s ease-in-out infinite;
375
- }
376
- .av-bar:nth-child(2) { animation-delay:.15s; }
377
- .av-bar:nth-child(3) { animation-delay:.3s; }
378
- .av-bar:nth-child(4) { animation-delay:.45s; }
379
- .av-bar:nth-child(5) { animation-delay:.6s; }
380
- .av-bar:nth-child(6) { animation-delay:.45s; }
381
- .av-bar:nth-child(7) { animation-delay:.3s; }
382
- .av-bar:nth-child(8) { animation-delay:.15s; }
383
- @keyframes vizAnim {
384
- 0%,100% { height:6px; opacity:.5; }
385
- 50% { height:30px; opacity:1; }
386
- }
387
- .audio-label { text-align:center; font-size:12px; font-weight:600; color:var(--green); }
388
-
389
- .v-status {
390
- padding:8px 20px; background:var(--panel); font-size:11px;
391
- color:var(--sub); border-top:1.5px solid var(--border);
392
- font-family:var(--mono);
393
- }
394
-
395
- /* HISTORY PANE */
396
- .hist-list { display:flex; flex-direction:column; gap:10px; }
397
- .hev {
398
- background:var(--surface); border:1.5px solid var(--border);
399
- border-radius:12px; padding:14px 16px;
400
- display:flex; align-items:center; gap:14px;
401
- box-shadow:0 1px 6px var(--shadow);
402
- }
403
- .hev-dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; }
404
- .hev-info { flex:1; }
405
- .hev-type { font-size:12px; font-weight:700; text-transform:capitalize; }
406
- .hev-time { font-size:10px; color:var(--muted); margin-top:2px; font-family:var(--mono); }
407
-
408
- /* STATUS BAR */
409
- .status-bar {
410
- background:var(--panel); border-top:1.5px solid var(--border);
411
- padding:8px 28px; font-size:11px; color:var(--muted);
412
- display:flex; align-items:center; gap:8px; font-family:var(--mono);
413
- }
414
-
415
- /* EMPTY */
416
- .empty-state {
417
- text-align:center; padding:50px 20px; color:var(--sub);
418
- }
419
- .empty-state .icon { font-size:42px; opacity:.2; margin-bottom:12px; }
420
- .empty-state p { font-size:13px; }
421
-
422
- /* TOAST */
423
- .toast {
424
- position:fixed; bottom:22px; right:22px; z-index:9999;
425
- padding:11px 18px; border-radius:10px; font-size:12px;
426
- font-weight:600; max-width:300px; pointer-events:none;
427
- transform:translateY(60px); opacity:0;
428
- transition:all .28s cubic-bezier(.4,0,.2,1);
429
- box-shadow:0 4px 20px var(--shadow);
430
- }
431
- .toast.on { transform:translateY(0); opacity:1; }
432
- .toast.ok { background:rgba(22,163,74,.1); border:1.5px solid rgba(22,163,74,.3); color:var(--green); }
433
- .toast.err { background:rgba(220,38,38,.08); border:1.5px solid rgba(220,38,38,.25); color:var(--red); }
434
- .toast.info { background:rgba(61,110,255,.08); border:1.5px solid rgba(61,110,255,.2); color:var(--accent); }
435
-
436
- /* Spinner */
437
- .spinner {
438
- width:18px; height:18px; border:2.5px solid rgba(255,255,255,.3);
439
- border-top-color:#fff; border-radius:50%;
440
- animation:spin .7s linear infinite; display:inline-block;
441
- }
442
- @keyframes spin { to { transform:rotate(360deg); } }
443
-
444
- @media(max-width:640px) {
445
- .header { padding:10px 14px; }
446
- .content { padding:14px; }
447
- .tabs { padding:0 14px; }
448
- .login-card { padding:32px 24px 28px; }
449
- .watch-actions { gap:7px; }
450
- }
451
- </style>
452
- </head>
453
- <body>
454
-
455
- <!-- Theme toggle -->
456
- <button class="theme-btn" id="theme-toggle" title="Changer le thème" onclick="toggleTheme()">☀️</button>
457
-
458
- <!-- ═══════════ LOGIN ═══════════ -->
459
- <div id="login-screen">
460
- <div class="login-wrap">
461
- <div class="login-card">
462
- <div class="login-brand">
463
- <div class="brand-icon">🔒</div>
464
- <div class="brand-text">
465
- <div class="brand-name">SecureView</div>
466
- <div class="brand-sub">Espace Client</div>
467
- </div>
468
- </div>
469
- <div class="login-title">Connexion</div>
470
- <div class="login-desc">Entrez votre ID ou nom de compte pour accéder à votre espace personnel.</div>
471
- <div class="field">
472
- <label>Identifiant / Nom de compte</label>
473
- <input type="text" id="login-id" placeholder="Ex: MARIE_01 ou votre ID…" autocomplete="off" spellcheck="false">
474
- </div>
475
- <button class="login-btn" id="login-btn" onclick="doLogin()">
476
- <span id="login-btn-text">Connexion →</span>
477
- </button>
478
- <div class="login-err" id="login-err"></div>
479
- <div class="login-note">Aucun mot de passe requis.<br>Utilisez uniquement l'identifiant fourni par l'administrateur.</div>
480
- </div>
481
- </div>
482
- </div>
483
-
484
- <!-- ═══════════ APP ═══════════ -->
485
- <div id="app" style="display:none; flex-direction:column; min-height:100vh;">
486
-
487
- <div class="header">
488
- <div class="h-left">
489
- <div class="h-logo">🔒</div>
490
- <div>
491
- <div class="h-title">SecureView</div>
492
- </div>
493
- </div>
494
- <div class="h-right">
495
- <div class="ws-pill off" id="ws-pill">
496
- <div class="ws-dot"></div>
497
- <span id="ws-txt">Déconnecté</span>
498
- </div>
499
- <button class="btn-logout" onclick="doLogout()">🚪 Déconnexion</button>
500
- </div>
501
- </div>
502
-
503
- <div class="tabs">
504
- <div class="tab active" onclick="showPane('watch')">🎥 Surveillance</div>
505
- <div class="tab" onclick="showPane('profile')">👤 Mon Compte</div>
506
- <div class="tab" onclick="showPane('history')">📊 Historique</div>
507
- </div>
508
-
509
- <div class="content">
510
-
511
- <!-- SURVEILLANCE PANE -->
512
- <div class="pane active" id="pane-watch">
513
- <div class="watch-header">
514
- <div class="section-label">
515
- <div class="dot-live"></div>
516
- Vue en direct
517
- </div>
518
- <div id="target-status"></div>
519
- </div>
520
-
521
- <div class="watch-card">
522
- <div class="watch-card-head">
523
- <div>
524
- <div class="wch-name" id="wch-name">—</div>
525
- <div class="wch-id" id="wch-id">—</div>
526
- </div>
527
- <div id="wch-badge"></div>
528
- </div>
529
-
530
- <div class="watch-actions">
531
- <button class="wbtn wbtn-watch" id="btn-watch" onclick="startStream()" disabled>
532
- 🎥 Voir en direct
533
- </button>
534
- <button class="wbtn wbtn-listen" id="btn-listen" onclick="toggleListen()" disabled>
535
- 🎧 Écouter
536
- </button>
537
- <button class="wbtn wbtn-call-auto" id="btn-auto" onclick="callMode('auto')" disabled>
538
- 📞 Appel Auto
539
- </button>
540
- <button class="wbtn wbtn-call-man" id="btn-man" onclick="callMode('manual')" disabled>
541
- 📲 Appel Manuel
542
- </button>
543
- <button class="wbtn wbtn-hist" onclick="showPane('history')">
544
- 📊 Historique
545
- </button>
546
- </div>
547
-
548
- <div class="video-area" id="video-area">
549
- <video id="user-video" autoplay playsinline class="front"></video>
550
- <div class="cam-label" id="cam-label">📷 Frontale</div>
551
- <button class="btn-flip" id="btn-flip" onclick="flipCamera()" title="Changer de caméra">🔄</button>
552
- <div class="video-placeholder" id="video-ph">
553
- <div class="icon">📹</div>
554
- <p>Appuyez sur <strong>Voir en direct</strong> pour activer la caméra.<br>
555
- L'admin pourra vous voir via WebRTC.<br>
556
- Utilisez 🔄 pour choisir frontale ou dorsale.</p>
557
- </div>
558
- </div>
559
-
560
- <div class="audio-area" id="audio-area">
561
- <div class="audio-viz">
562
- <div class="av-bar" style="height:14px"></div>
563
- <div class="av-bar" style="height:22px"></div>
564
- <div class="av-bar" style="height:30px"></div>
565
- <div class="av-bar" style="height:24px"></div>
566
- <div class="av-bar" style="height:18px"></div>
567
- <div class="av-bar" style="height:24px"></div>
568
- <div class="av-bar" style="height:30px"></div>
569
- <div class="av-bar" style="height:14px"></div>
570
- </div>
571
- <div class="audio-label">🎧 Écoute en cours…</div>
572
- </div>
573
-
574
- <div class="v-status" id="v-status">En attente…</div>
575
- </div>
576
- </div>
577
-
578
- <!-- PROFILE PANE -->
579
- <div class="pane" id="pane-profile">
580
- <div class="profile-grid">
581
- <div class="info-card">
582
- <div class="info-card-title">👤 Informations du compte</div>
583
- <div class="info-row">
584
- <span class="info-key">Nom</span>
585
- <span class="info-val" id="p-name">—</span>
586
- </div>
587
- <div class="info-row">
588
- <span class="info-key">Email</span>
589
- <span class="info-val" id="p-email">—</span>
590
- </div>
591
- <div class="info-row">
592
- <span class="info-key">Téléphone</span>
593
- <span class="info-val" id="p-phone">—</span>
594
- </div>
595
- <div class="info-row">
596
- <span class="info-key">Statut</span>
597
- <span class="info-val" id="p-status">—</span>
598
- </div>
599
- </div>
600
- <div class="info-card">
601
- <div class="info-card-title">🔑 Identifiants</div>
602
- <div class="info-row">
603
- <span class="info-key">Client ID</span>
604
- <span class="info-val mono" id="p-cid" onclick="copyText(this)" title="Cliquer pour copier">—</span>
605
- </div>
606
- <div class="info-row">
607
- <span class="info-key">Créé le</span>
608
- <span class="info-val" id="p-created">—</span>
609
- </div>
610
- <div class="info-row">
611
- <span class="info-key">Dernière activité</span>
612
- <span class="info-val" id="p-seen">—</span>
613
- </div>
614
- <div class="info-row">
615
- <span class="info-key">Dernière IP</span>
616
- <span class="info-val mono" id="p-ip" style="font-size:11px;">—</span>
617
- </div>
618
- </div>
619
- </div>
620
- </div>
621
-
622
- <!-- HISTORY PANE -->
623
- <div class="pane" id="pane-history">
624
- <div class="section-label" style="margin-bottom:18px;">📊 Historique des connexions</div>
625
- <div id="hist-list">
626
- <div class="empty-state"><div class="icon">📋</div><p>Chargement…</p></div>
627
- </div>
628
- </div>
629
-
630
- </div>
631
-
632
- <div class="status-bar" id="status-bar">
633
- <span>⬤</span>
634
- <span id="sb-text">Initialisation…</span>
635
- </div>
636
- </div>
637
-
638
- <div class="toast" id="toast"></div>
639
-
640
- <script>
641
- /* ════════════════════════════════════════════════
642
- CONFIG
643
- ════════════════════════════════════════════════ */
644
- const SERVER = 'https://tafita1206-admin.hf.space';
645
- const API = SERVER + '/api';
646
- const WS_URL = SERVER.replace('https://','wss://').replace('http://','ws://');
647
-
648
- /* ════════════════════════════════════════════════
649
- THEME
650
- ════════════════════════════════════════════════ */
651
- function toggleTheme() {
652
- const isDark = document.documentElement.dataset.theme === 'dark';
653
- document.documentElement.dataset.theme = isDark ? '' : 'dark';
654
- document.getElementById('theme-toggle').textContent = isDark ? '☀️' : '🌙';
655
- localStorage.setItem('sv_theme', isDark ? 'light' : 'dark');
656
- }
657
- (function(){
658
- const t = localStorage.getItem('sv_theme');
659
- if (t === 'dark') { document.documentElement.dataset.theme = 'dark'; document.getElementById('theme-toggle').textContent = '🌙'; }
660
- })();
661
-
662
- /* ════════════════════════════════════════════════
663
- STATE
664
- ════════════════════════════════════════════════ */
665
- let myClientId = null;
666
- let myClientData = null;
667
- let ws = null;
668
- let pc = null;
669
- let watching = false;
670
- let listening = false;
671
- let adminSIDTarget = null;
672
- let reconnT = null;
673
- let retryDelay = 2000;
674
-
675
- const RTC_CFG = {
676
- iceServers:[
677
- {urls:['stun:stun.l.google.com:19302','stun:stun1.l.google.com:19302']},
678
- {urls:'stun:stun.cloudflare.com:3478'},
679
- ],
680
- iceCandidatePoolSize:10,
681
- };
682
-
683
- /* ════════════════════════════════════════════════
684
- ENTER / KEYDOWN
685
- ════════════════════════════════════════════════ */
686
- document.getElementById('login-id').addEventListener('keydown', e => {
687
- if(e.key === 'Enter') doLogin();
688
- });
689
-
690
- /* ════════════════════════════════════════════════
691
- LOGIN — recherche par ID OU par nom
692
- ════════════════════════════════════════════════ */
693
- async function doLogin(){
694
- const raw = document.getElementById('login-id').value.trim();
695
- if(!raw) { showErr('Veuillez entrer votre identifiant.'); return; }
696
-
697
- const btn = document.getElementById('login-btn');
698
- const txt = document.getElementById('login-btn-text');
699
- btn.disabled = true;
700
- txt.innerHTML = '<span class="spinner"></span>';
701
-
702
- hideErr();
703
-
704
- try {
705
- // 1. Essayer comme ID direct
706
- let res = await fetch(`${API}/clients/${encodeURIComponent(raw)}`);
707
- let data = null;
708
-
709
- if(res.ok) {
710
- const j = await res.json();
711
- if(j.success) data = j.data;
712
- }
713
-
714
- // 2. Si pas trouvé, chercher par nom dans la liste
715
- if(!data) {
716
- const listRes = await fetch(`${API}/clients`);
717
- const listJ = await listRes.json();
718
- if(listJ.success && listJ.data) {
719
- const found = listJ.data.find(c =>
720
- c.name && c.name.toLowerCase() === raw.toLowerCase()
721
- );
722
- if(found) data = found;
723
- }
724
- }
725
-
726
- if(!data) {
727
- showErr('❌ Identifiant ou nom introuvable. Vérifiez avec l\'administrateur.');
728
- btn.disabled = false; txt.textContent = 'Connexion →';
729
- return;
730
- }
731
-
732
- // Connexion réussie
733
- myClientId = data.client_id;
734
- myClientData = data;
735
- sessionStorage.setItem('sv_client', myClientId);
736
-
737
- document.getElementById('login-screen').style.display = 'none';
738
- document.getElementById('app').style.display = 'flex';
739
-
740
- initApp();
741
-
742
- } catch(e) {
743
- showErr('❌ Erreur réseau. Vérifiez votre connexion.');
744
- btn.disabled = false; txt.textContent = 'Connexion →';
745
- }
746
- }
747
-
748
- function showErr(msg){
749
- const el = document.getElementById('login-err');
750
- el.textContent = msg; el.classList.add('show');
751
- }
752
- function hideErr(){
753
- document.getElementById('login-err').classList.remove('show');
754
- }
755
-
756
- function doLogout(){
757
- sessionStorage.removeItem('sv_client');
758
- if(ws){ try{ws.close();}catch(e){} ws=null; }
759
- closePC();
760
- myClientId = null; myClientData = null;
761
- document.getElementById('app').style.display = 'none';
762
- document.getElementById('login-screen').style.display = 'flex';
763
- document.getElementById('login-id').value = '';
764
- const btn = document.getElementById('login-btn');
765
- btn.disabled = false;
766
- document.getElementById('login-btn-text').textContent = 'Connexion →';
767
- }
768
-
769
- /* ════════════════════════════════════════════════
770
- INIT APP
771
- ════════════════════════════════════════════════ */
772
- function initApp(){
773
- renderProfile(myClientData);
774
- updateWatchCard(myClientData);
775
- connectWS();
776
- loadHistory();
777
- setInterval(()=>{ loadHistory(); }, 60000);
778
- }
779
-
780
- /* ════════════════════════════════════════════════
781
- WEBSOCKET — register as CLIENT
782
- ════════════════════════════════════════════════ */
783
- function connectWS(){
784
- clearTimeout(reconnT);
785
- if(ws && ws.readyState <= WebSocket.OPEN){ try{ws.close();}catch(e){} }
786
-
787
- try {
788
- ws = new WebSocket(WS_URL);
789
-
790
- ws.onopen = () => {
791
- retryDelay = 2000;
792
- setWsUI(true);
793
- ws.send(JSON.stringify({ type:'register_client', clientId:myClientId }));
794
- setSB('Connecté au serveur');
795
- };
796
-
797
- ws.onmessage = (ev) => {
798
- let d; try{ d=JSON.parse(ev.data); }catch(e){ return; }
799
- handleMsg(d);
800
- };
801
-
802
- ws.onerror = () => {};
803
- ws.onclose = () => {
804
- setWsUI(false);
805
- setSB('Déconnecté — reconnexion dans '+(retryDelay/1000).toFixed(0)+'s…');
806
- reconnT = setTimeout(connectWS, retryDelay);
807
- retryDelay = Math.min(retryDelay*1.5, 30000);
808
- };
809
- } catch(e) { reconnT = setTimeout(connectWS, retryDelay); }
810
- }
811
-
812
- function wsSend(data){
813
- try{ if(ws && ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify(data)); }
814
- catch(e){}
815
- }
816
-
817
- /* ════════════════════════════════════════════════
818
- WS MESSAGES
819
- ════════════════════════════════════════════════ */
820
- async function handleMsg(d){
821
- switch(d.type){
822
-
823
- case 'registration_success':
824
- toast('✅ Enregistré sur le serveur','ok');
825
- setSB('Enregistré · ID: '+myClientId);
826
- break;
827
-
828
- case 'error':
829
- toast('⚠️ '+d.message,'err');
830
- break;
831
-
832
- /* Admin demande le flux vidéo/audio */
833
- case 'incoming_call':
834
- adminSIDTarget = d.adminSID;
835
- toast('📞 Appel entrant — connexion…','info');
836
- setSB('Appel reçu de l\'administrateur');
837
- break;
838
-
839
- /* WebRTC: réception offer de l'admin (admin surveille depuis l'extérieur)
840
- → ici le client répond avec son answer + flux */
841
- case 'webrtc_offer':
842
- adminSIDTarget = d.adminSID;
843
- await handleIncomingOffer(d.offer, d.adminSID);
844
- break;
845
-
846
- case 'webrtc_ice':
847
- if(pc && d.candidate){
848
- try{ await pc.addIceCandidate(new RTCIceCandidate(d.candidate)); }
849
- catch(e){ console.warn('[RTC client] ICE warn:',e.message); }
850
- }
851
- break;
852
-
853
- case 'webrtc_end':
854
- closePC();
855
- toast('📹 Surveillance terminée par l\'admin','info');
856
- setVStatus('Surveillance arrêtée — en attente…');
857
- // Ne pas stopper la preview locale si elle était ouverte
858
- if(!watching){
859
- if(localStream){ localStream.getTracks().forEach(t=>t.stop()); localStream=null; }
860
- }
861
- break;
862
- }
863
- }
864
-
865
-
866
-
867
- /* ════════════════════════════════════════════════
868
- CAMÉRA — sélection frontale/dorsale + preview
869
- Le flux réel est envoyé UNIQUEMENT quand l'admin
870
- envoie un webrtc_offer. Ici on gère :
871
- 1. Le choix de caméra (frontale par défaut)
872
- 2. La preview locale (optionnelle, pour se voir)
873
- 3. La réponse à l'offer admin avec la bonne caméra
874
- ════════════════════════════════════════════════ */
875
- let currentFacing = 'user'; // 'user'=frontale, 'environment'=dorsale
876
- let localStream = null; // flux caméra local (preview + à envoyer)
877
- let audioOnlyMode = false; // true quand écoute seule
878
-
879
- /* ── Ouvre la caméra en preview locale ── */
880
- async function startStream(){
881
- if(watching){ stopVideoUI(); return; }
882
-
883
- const v = document.getElementById('user-video');
884
- try {
885
- // Stopper l'ancien flux si existant
886
- if(localStream){ localStream.getTracks().forEach(t=>t.stop()); localStream=null; }
887
-
888
- localStream = await navigator.mediaDevices.getUserMedia({
889
- video: { facingMode: currentFacing },
890
- audio: true
891
- });
892
-
893
- v.srcObject = localStream;
894
- v.style.display = 'block';
895
- document.getElementById('video-ph').style.display = 'none';
896
-
897
- // Mirror frontale seulement
898
- currentFacing === 'user' ? v.classList.add('front') : v.classList.remove('front');
899
-
900
- // Label + flip
901
- showCamUI(currentFacing);
902
-
903
- watching = true;
904
- audioOnlyMode = false;
905
- document.getElementById('btn-watch').innerHTML = '⏹ Arrêter';
906
- setVStatus(currentFacing === 'user'
907
- ? '🤳 Caméra frontale — prête à être vue'
908
- : '📷 Caméra dorsale — prête à être vue');
909
- toast(currentFacing === 'user' ? '🤳 Caméra frontale active' : '📷 Caméra dorsale active','ok');
910
-
911
- // Si un PC WebRTC est déjà en cours → remplacer les tracks
912
- if(pc){ replaceTracksOnPC(localStream); }
913
-
914
- } catch(e) {
915
- toast('❌ Caméra refusée: '+e.message,'err');
916
- }
917
- }
918
-
919
- /* ── Flip frontale ↔ dorsale ── */
920
- async function flipCamera(){
921
- const btn = document.getElementById('btn-flip');
922
- btn.classList.add('flipping');
923
- setTimeout(()=>btn.classList.remove('flipping'), 420);
924
-
925
- currentFacing = currentFacing === 'user' ? 'environment' : 'user';
926
-
927
- // Si la preview est active → on change le flux en direct
928
- if(watching && localStream){
929
- try {
930
- localStream.getTracks().forEach(t=>t.stop());
931
- localStream = await navigator.mediaDevices.getUserMedia({
932
- video: { facingMode: currentFacing },
933
- audio: true
934
- });
935
- const v = document.getElementById('user-video');
936
- v.srcObject = localStream;
937
- currentFacing === 'user' ? v.classList.add('front') : v.classList.remove('front');
938
- showCamUI(currentFacing);
939
- setVStatus(currentFacing === 'user' ? '🤳 Caméra frontale' : '📷 Caméra dorsale');
940
- toast(currentFacing === 'user' ? '🤳 Caméra frontale' : '📷 Caméra dorsale','ok');
941
- // Mettre à jour les tracks dans le PC en cours si actif
942
- if(pc){ replaceTracksOnPC(localStream); }
943
- } catch(e) {
944
- toast('❌ Changement caméra échoué: '+e.message,'err');
945
- }
946
- } else {
947
- // Juste changer le choix, sans preview active
948
- showCamUI(currentFacing);
949
- toast(currentFacing === 'user' ? '🤳 Frontale sélectionnée' : '📷 Dorsale sélectionnée','info');
950
- }
951
- }
952
-
953
- function showCamUI(facing){
954
- const lbl = document.getElementById('cam-label');
955
- lbl.textContent = facing === 'user' ? '🤳 Frontale' : '📷 Dorsale';
956
- lbl.classList.add('show');
957
- document.getElementById('btn-flip').classList.add('show');
958
- }
959
-
960
- /* ── Stoppe la preview locale ── */
961
- function stopVideoUI(){
962
- if(localStream && !pc){
963
- // ne stopper le stream que si aucun PC WebRTC actif
964
- localStream.getTracks().forEach(t=>t.stop());
965
- localStream = null;
966
- }
967
- const v = document.getElementById('user-video');
968
- v.srcObject = null;
969
- v.style.display = 'none';
970
- v.classList.add('front');
971
- document.getElementById('video-ph').style.display = 'flex';
972
- document.getElementById('btn-flip').classList.remove('show');
973
- document.getElementById('cam-label').classList.remove('show');
974
- watching = false; audioOnlyMode = false;
975
- document.getElementById('btn-watch').innerHTML = '🎥 Voir en direct';
976
- setVStatus(pc ? '📡 Flux actif vers l\'admin…' : 'En attente…');
977
- }
978
-
979
- /* ── Remplace les tracks dans le PeerConnection (flip à chaud) ── */
980
- function replaceTracksOnPC(stream){
981
- if(!pc) return;
982
- const senders = pc.getSenders();
983
- stream.getTracks().forEach(newTrack => {
984
- const sender = senders.find(s => s.track && s.track.kind === newTrack.kind);
985
- if(sender){ sender.replaceTrack(newTrack).catch(e=>console.warn('[RTC] replaceTrack:',e)); }
986
- else { pc.addTrack(newTrack, stream); }
987
- });
988
- }
989
-
990
- /* ════════════════════════════════════════════════
991
- RÉPONSE À L'OFFER ADMIN (webrtc_offer reçu)
992
- → On envoie notre caméra (celle choisie) + micro
993
- ════════════════════════════════════════════════ */
994
- async function handleIncomingOffer(offer, fromAdminSID){
995
- closePC();
996
-
997
- toast('📡 Admin connecté — envoi du flux…','info');
998
- setVStatus('Offer reçu — ouverture de la caméra…');
999
-
1000
- // Récupérer le flux avec la caméra sélectionnée
1001
- // Si on a déjà un flux local → le réutiliser directement
1002
- let stream = localStream;
1003
- if(!stream || stream.getTracks().some(t=>t.readyState==='ended')){
1004
- try {
1005
- if(audioOnlyMode){
1006
- stream = await navigator.mediaDevices.getUserMedia({ audio:true, video:false });
1007
- } else {
1008
- try {
1009
- stream = await navigator.mediaDevices.getUserMedia({
1010
- video: { facingMode: currentFacing },
1011
- audio: true
1012
- });
1013
- } catch(e){
1014
- // fallback audio seul si caméra refusée
1015
- stream = await navigator.mediaDevices.getUserMedia({ audio:true, video:false });
1016
- toast('⚠️ Caméra refusée — audio seulement','info');
1017
- }
1018
- }
1019
- localStream = stream;
1020
- // Mettre à jour la preview si ouverte
1021
- if(watching){
1022
- const v = document.getElementById('user-video');
1023
- v.srcObject = stream;
1024
- }
1025
- } catch(e2){
1026
- toast('❌ Impossible d\'accéder à la caméra/micro','err');
1027
- return;
1028
- }
1029
- }
1030
-
1031
- // Créer PeerConnection
1032
- pc = new RTCPeerConnection(RTC_CFG);
1033
-
1034
- // Ajouter les tracks du flux
1035
- stream.getTracks().forEach(t => pc.addTrack(t, stream));
1036
-
1037
- // ICE candidates → serveur → admin
1038
- pc.onicecandidate = ({ candidate }) => {
1039
- if(!candidate) return;
1040
- wsSend({ type:'webrtc_ice', direction:'to_admin', targetId:fromAdminSID, candidate });
1041
- };
1042
-
1043
- pc.oniceconnectionstatechange = () => {
1044
- const s = pc?.iceConnectionState;
1045
- console.log('[RTC client] ICE:', s);
1046
- if(s==='connected'||s==='completed'){
1047
- setVStatus('🔴 En direct — vu par l\'admin');
1048
- toast('🔴 Surveillance active','err');
1049
- }
1050
- if(s==='failed') setVStatus('⚠️ Connexion ICE échouée');
1051
- if(s==='disconnected') setVStatus('⚠️ Admin déconnecté');
1052
- };
1053
-
1054
- pc.onconnectionstatechange = () => {
1055
- if(pc?.connectionState === 'closed'){ closePC(); }
1056
- };
1057
-
1058
- try {
1059
- await pc.setRemoteDescription(new RTCSessionDescription(offer));
1060
- const answer = await pc.createAnswer();
1061
- await pc.setLocalDescription(answer);
1062
- wsSend({ type:'webrtc_answer', targetAdminSID:fromAdminSID, answer });
1063
- setVStatus('📡 Flux caméra envoyé à l\'admin…');
1064
- console.log('[RTC client] Answer envoyé à admin:', fromAdminSID);
1065
- } catch(e) {
1066
- console.error('[RTC client] createAnswer error:', e);
1067
- toast('❌ Erreur WebRTC: '+e.message,'err');
1068
- closePC();
1069
- }
1070
- }
1071
-
1072
- /* ════════════════════════════════════════════════
1073
- ÉCOUTE AUDIO — micro actif sans preview vidéo
1074
- ════════════════════════════════════════════════ */
1075
- async function toggleListen(){
1076
- if(listening){ stopListen(); return; }
1077
- try {
1078
- if(localStream){ localStream.getTracks().forEach(t=>t.stop()); localStream=null; }
1079
- localStream = await navigator.mediaDevices.getUserMedia({ audio:true, video:false });
1080
- listening = true;
1081
- audioOnlyMode = true;
1082
- document.getElementById('btn-listen').classList.add('active-listen');
1083
- document.getElementById('btn-listen').textContent = '🔇 Arrêter écoute';
1084
- document.getElementById('audio-area').classList.add('show');
1085
- setVStatus('🎧 Écoute audio active — prête');
1086
- toast('🎧 Micro actif — prêt pour l\'admin','ok');
1087
- if(pc){ replaceTracksOnPC(localStream); }
1088
- } catch(e) {
1089
- toast('❌ Micro refusé: '+e.message,'err');
1090
- }
1091
- }
1092
-
1093
- function stopListen(){
1094
- if(localStream && !pc){ localStream.getTracks().forEach(t=>t.stop()); localStream=null; }
1095
- listening = false; audioOnlyMode = false;
1096
- document.getElementById('btn-listen').classList.remove('active-listen');
1097
- document.getElementById('btn-listen').textContent = '🎧 Écouter';
1098
- document.getElementById('audio-area').classList.remove('show');
1099
- if(!watching) setVStatus('En attente…');
1100
- }
1101
-
1102
- /* ════════════════════════════════════════════════
1103
- CALL MODES
1104
- ════════════════════════════════════════════════ */
1105
- function callMode(mode){
1106
- wsSend({ type:'call_client', clientId:myClientId, mode });
1107
- toast(`📞 Signal ${mode} envoyé`,'info');
1108
- }
1109
-
1110
- /* ════════════════════════════════════════════════
1111
- CLOSE PC — ferme uniquement la session WebRTC,
1112
- pas le flux local (preview reste active)
1113
- ════════════════════════════════════════════════ */
1114
- function closePC(){
1115
- if(pc){ try{pc.close();}catch(e){} pc=null; }
1116
- }
1117
-
1118
- /* ════════════════════════════════════════════════
1119
- RENDER PROFILE
1120
- ════════════════════════════════════════════════ */
1121
- function renderProfile(c){
1122
- if(!c) return;
1123
- set('p-name', c.name||'—');
1124
- set('p-email', c.email||'—');
1125
- set('p-phone', c.phone||'—');
1126
- set('p-cid', c.client_id||'—');
1127
- set('p-created', fmt(c.created_at));
1128
- set('p-seen', ago(c.last_seen));
1129
- set('p-ip', c.last_ip||'—');
1130
-
1131
- const on = c.isOnline || c.status==='online';
1132
- document.getElementById('p-status').innerHTML =
1133
- `<span class="status-badge ${on?'online':'offline'}">${on?'🟢 En ligne':'⚫ Hors ligne'}</span>`;
1134
- }
1135
-
1136
- function updateWatchCard(c){
1137
- if(!c) return;
1138
- set('wch-name', c.name||'—');
1139
- set('wch-id', '🔑 '+c.client_id);
1140
-
1141
- const on = c.isOnline || c.status==='online';
1142
- document.getElementById('wch-badge').innerHTML =
1143
- `<span class="status-badge ${on?'online':'offline'}">${on?'🟢 En ligne':'⚫ Hors ligne'}</span>`;
1144
-
1145
- // Enable buttons when online
1146
- ['btn-watch','btn-listen','btn-auto','btn-man'].forEach(id=>{
1147
- document.getElementById(id).disabled = false; // always enable own device controls
1148
- });
1149
- }
1150
-
1151
- /* ════════════════════════════════════════════════
1152
- LOAD HISTORY
1153
- ════════════════════════════════════════════════ */
1154
- async function loadHistory(){
1155
- try {
1156
- const d = await (await fetch(`${API}/clients/${myClientId}/history`)).json();
1157
- const evs = d.data||[];
1158
- const cols = { connect:'var(--green)', disconnect:'var(--red)', register:'var(--accent)' };
1159
- const list = document.getElementById('hist-list');
1160
- list.innerHTML = evs.length ? evs.map(e=>`
1161
- <div class="hev">
1162
- <div class="hev-dot" style="background:${cols[e.event_type]||'var(--muted)'}"></div>
1163
- <div class="hev-info">
1164
- <div class="hev-type">${e.event_type}</div>
1165
- <div class="hev-time">${fmt(e.timestamp)} · ${new Date(e.timestamp).toLocaleTimeString('fr-FR')}</div>
1166
- </div>
1167
- ${e.meta?.ip?`<div style="font-size:10px;color:var(--muted);font-family:var(--mono);">${esc(e.meta.ip)}</div>`:''}
1168
- </div>`).join('') :
1169
- '<div class="empty-state"><div class="icon">📋</div><p>Aucun historique disponible.</p></div>';
1170
- } catch(e) {}
1171
- }
1172
-
1173
- /* ════════════════════════════════════════════════
1174
- TABS
1175
- ════════════════════════════════════════════════ */
1176
- function showPane(n){
1177
- ['watch','profile','history'].forEach((p,i)=>{
1178
- document.querySelectorAll('.tab')[i].classList.toggle('active', p===n);
1179
- document.getElementById(`pane-${p}`).classList.toggle('active', p===n);
1180
- });
1181
- if(n==='history') loadHistory();
1182
- if(n==='profile' && myClientId){
1183
- // refresh profile data
1184
- fetch(`${API}/clients/${myClientId}`).then(r=>r.json()).then(d=>{ if(d.success) renderProfile(d.data); }).catch(()=>{});
1185
- }
1186
- }
1187
-
1188
- /* ════════════════════════════════════════════════
1189
- WS UI
1190
- ════════════════════════════════════════════════ */
1191
- function setWsUI(on){
1192
- document.getElementById('ws-pill').className = `ws-pill ${on?'on':'off'}`;
1193
- document.getElementById('ws-txt').textContent = on?'Connecté':'Déconnecté';
1194
- }
1195
-
1196
- function setVStatus(msg){
1197
- const el = document.getElementById('v-status'); if(el) el.textContent = msg;
1198
- }
1199
-
1200
- function setSB(msg){
1201
- document.getElementById('sb-text').textContent = msg;
1202
- }
1203
-
1204
- /* ════════════════════════════════════════════════
1205
- UTILS
1206
- ════════════════════════════════════════════════ */
1207
- let toastT = null;
1208
- function toast(msg, type='info'){
1209
- const t = document.getElementById('toast');
1210
- t.textContent = msg; t.className = `toast ${type} on`;
1211
- clearTimeout(toastT); toastT = setTimeout(()=>t.classList.remove('on'), 3500);
1212
- }
1213
-
1214
- function set(id, val){ const el=document.getElementById(id); if(el) el.textContent=val; }
1215
-
1216
- function copyText(el){
1217
- navigator.clipboard.writeText(el.textContent).then(()=>toast('📋 Copié !','ok'));
1218
- }
1219
-
1220
- function ago(ds){
1221
- if(!ds) return 'jamais';
1222
- const s = Math.floor((Date.now()-new Date(ds))/1000);
1223
- if(s<60) return `il y a ${s}s`;
1224
- if(s<3600) return `il y a ${Math.floor(s/60)}min`;
1225
- if(s<86400) return `il y a ${Math.floor(s/3600)}h`;
1226
- return `il y a ${Math.floor(s/86400)}j`;
1227
- }
1228
-
1229
- function fmt(ds){
1230
- if(!ds) return 'N/A';
1231
- return new Date(ds).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'});
1232
- }
1233
-
1234
- function esc(s){
1235
- return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1236
- }
1237
-
1238
- /* ════════════════════════════════════════════════
1239
- AUTO RESTORE SESSION
1240
- ════════════════════════════════════════════════ */
1241
- (async function(){
1242
- const saved = sessionStorage.getItem('sv_client');
1243
- if(!saved) return;
1244
- try {
1245
- const res = await fetch(`${API}/clients/${encodeURIComponent(saved)}`);
1246
- const j = await res.json();
1247
- if(j.success && j.data){
1248
- myClientId = saved;
1249
- myClientData = j.data;
1250
- document.getElementById('login-screen').style.display = 'none';
1251
- document.getElementById('app').style.display = 'flex';
1252
- initApp();
1253
- } else {
1254
- sessionStorage.removeItem('sv_client');
1255
- }
1256
- } catch(e) {}
1257
- })();
1258
- </script>
1259
- </body>
1260
- </html>