@imenam/mcp-messenger 1.0.29 → 1.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +764 -707
- package/dist/src/cli/watch.d.ts +3 -1
- package/dist/src/cli/watch.js +12 -2
- package/dist/src/client/messenger-client.d.ts +21 -1
- package/dist/src/client/messenger-client.js +31 -9
- package/dist/src/core/attachments.d.ts +16 -0
- package/dist/src/core/attachments.js +28 -0
- package/dist/src/core/store.d.ts +34 -3
- package/dist/src/core/store.js +404 -216
- package/dist/src/core/types.d.ts +41 -0
- package/dist/src/mcp/start.js +25 -22
- package/dist/src/mcp/tools.js +186 -9
- package/dist/src/server/app.js +89 -12
- package/dist/src/server/ui/app.js +867 -7
- package/dist/src/server/ui/index.html +59 -0
- package/dist/src/server/ui/styles.css +751 -20
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +16 -2
- package/package.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Console de supervision : les routes /admin du serveur
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Console de supervision : les routes /admin du serveur. Deux familles de commandes
|
|
2
|
+
// écrivent : les liaisons entre boîtes (création, suppression), et les suppressions de
|
|
3
|
+
// boîtes, qui passent toutes par la même fenêtre — un aperçu de ce qui va partir, puis une
|
|
4
|
+
// confirmation en deux clics.
|
|
5
5
|
//
|
|
6
6
|
// Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
|
|
7
7
|
// jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
|
|
@@ -27,10 +27,24 @@ const ICONS = {
|
|
|
27
27
|
link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
|
|
28
28
|
chevron: ["m9 18 6-6-6-6"],
|
|
29
29
|
info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 11v5", "M12 8h.01"],
|
|
30
|
+
paperclip: ["m21 11-8.5 8.5a5 5 0 0 1-7-7L14 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L15 7"],
|
|
30
31
|
trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
|
|
32
|
+
unlink: [
|
|
33
|
+
"m18.84 12.25 1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71",
|
|
34
|
+
"m5.17 11.75-1.71 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71",
|
|
35
|
+
"M8 2v3",
|
|
36
|
+
"M2 8h3",
|
|
37
|
+
"M16 22v-3",
|
|
38
|
+
"M22 16h-3",
|
|
39
|
+
],
|
|
40
|
+
search: ["m21 21-4.3-4.3", "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"],
|
|
31
41
|
};
|
|
32
42
|
|
|
33
43
|
const state = {
|
|
44
|
+
/** "threads" | "links" — l'écran affiché, choisi par les onglets de la barre du haut. */
|
|
45
|
+
view: "threads",
|
|
46
|
+
/** Liaisons du serveur, relues à chaque rafraîchissement. */
|
|
47
|
+
links: [],
|
|
34
48
|
/**
|
|
35
49
|
* Ce sur quoi la liste des fils est filtrée.
|
|
36
50
|
*
|
|
@@ -107,6 +121,13 @@ const el = {
|
|
|
107
121
|
purgePreviewEmpty: document.getElementById("purge-preview-empty"),
|
|
108
122
|
purgeResult: document.getElementById("purge-result"),
|
|
109
123
|
purgeConfirm: document.getElementById("purge-confirm"),
|
|
124
|
+
tabs: document.getElementById("tabs"),
|
|
125
|
+
linksBadge: document.getElementById("links-badge"),
|
|
126
|
+
links: document.getElementById("links"),
|
|
127
|
+
linksWindow: document.getElementById("links-window"),
|
|
128
|
+
linksScroll: document.getElementById("links-scroll"),
|
|
129
|
+
linksCanvas: document.getElementById("links-canvas"),
|
|
130
|
+
linksPanel: document.getElementById("links-panel"),
|
|
110
131
|
};
|
|
111
132
|
|
|
112
133
|
// --- Fabrique de noeuds ----------------------------------------------------
|
|
@@ -360,9 +381,12 @@ async function action(method, path, body) {
|
|
|
360
381
|
|
|
361
382
|
function setLoggedIn(loggedIn) {
|
|
362
383
|
el.login.hidden = loggedIn;
|
|
363
|
-
el.console.hidden = !loggedIn;
|
|
384
|
+
el.console.hidden = !loggedIn || state.view !== "threads";
|
|
385
|
+
el.links.hidden = !loggedIn || state.view !== "links";
|
|
386
|
+
el.tabs.hidden = !loggedIn;
|
|
364
387
|
el.controls.hidden = !loggedIn;
|
|
365
|
-
|
|
388
|
+
// La recherche porte sur les fils ; le volet des liaisons a la sienne.
|
|
389
|
+
el.searchBox.hidden = !loggedIn || state.view !== "threads";
|
|
366
390
|
if (!loggedIn) {
|
|
367
391
|
setStatus("idle", "non connecté");
|
|
368
392
|
el.token.focus();
|
|
@@ -961,6 +985,9 @@ function fillMessage(item, message, isLast) {
|
|
|
961
985
|
column.appendChild(content);
|
|
962
986
|
|
|
963
987
|
content.appendChild(messageText(message));
|
|
988
|
+
if (message.attachments && message.attachments.length > 0) {
|
|
989
|
+
content.appendChild(attachmentList(message.attachments));
|
|
990
|
+
}
|
|
964
991
|
|
|
965
992
|
const foot = node("div", "message-foot");
|
|
966
993
|
foot.appendChild(
|
|
@@ -986,6 +1013,30 @@ function messageText(message) {
|
|
|
986
1013
|
return container;
|
|
987
1014
|
}
|
|
988
1015
|
|
|
1016
|
+
/** Pièces jointes d'un message : un lien de téléchargement par fichier. */
|
|
1017
|
+
function attachmentList(attachments) {
|
|
1018
|
+
const list = node("ul", "attachments");
|
|
1019
|
+
for (const attachment of attachments) {
|
|
1020
|
+
const item = node("li");
|
|
1021
|
+
const link = node("a", "attachment");
|
|
1022
|
+
link.href = `/admin/attachments/${encodeURIComponent(attachment.attachment_id)}`;
|
|
1023
|
+
link.download = attachment.filename;
|
|
1024
|
+
link.title = attachment.mime_type;
|
|
1025
|
+
link.appendChild(icon("paperclip"));
|
|
1026
|
+
link.appendChild(node("span", "attachment-name", attachment.filename));
|
|
1027
|
+
link.appendChild(node("span", "attachment-size", formatSize(attachment.size)));
|
|
1028
|
+
item.appendChild(link);
|
|
1029
|
+
list.appendChild(item);
|
|
1030
|
+
}
|
|
1031
|
+
return list;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function formatSize(bytes) {
|
|
1035
|
+
if (bytes < 1024) return `${bytes} o`;
|
|
1036
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
|
1037
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
989
1040
|
// --- Détails d'un message --------------------------------------------------
|
|
990
1041
|
|
|
991
1042
|
/**
|
|
@@ -1159,8 +1210,12 @@ async function refreshThread() {
|
|
|
1159
1210
|
// --- Boucle de rafraîchissement --------------------------------------------
|
|
1160
1211
|
|
|
1161
1212
|
async function refresh(options) {
|
|
1213
|
+
if (state.view === "links") {
|
|
1214
|
+
await refreshLinks();
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1162
1217
|
try {
|
|
1163
|
-
const [overview, list] = await Promise.all([
|
|
1218
|
+
const [overview, list, links] = await Promise.all([
|
|
1164
1219
|
api("/admin/overview"),
|
|
1165
1220
|
api("/admin/threads", {
|
|
1166
1221
|
// Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
|
|
@@ -1170,10 +1225,13 @@ async function refresh(options) {
|
|
|
1170
1225
|
priority: state.filter === "high" ? "high" : undefined,
|
|
1171
1226
|
limit: 100,
|
|
1172
1227
|
}),
|
|
1228
|
+
api("/admin/links"),
|
|
1173
1229
|
]);
|
|
1174
1230
|
setLoggedIn(true);
|
|
1175
1231
|
setStatus("ok", `connecté · ${window.location.host}`);
|
|
1176
1232
|
renderAgents(overview);
|
|
1233
|
+
state.links = links.links;
|
|
1234
|
+
renderLinksBadge();
|
|
1177
1235
|
renderThreads(list.threads);
|
|
1178
1236
|
if (!options || !options.keepThread) {
|
|
1179
1237
|
if (state.threadId) await refreshThread();
|
|
@@ -1293,6 +1351,784 @@ for (const id of Object.keys(PANELS)) {
|
|
|
1293
1351
|
|
|
1294
1352
|
loadLayout();
|
|
1295
1353
|
|
|
1354
|
+
// --- Liaisons --------------------------------------------------------------
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Carte des liaisons : une colonne par agent, celle dont la dernière boîte est la plus
|
|
1358
|
+
* récente à gauche ; en hauteur, l'heure exacte de création de chaque boîte, la plus
|
|
1359
|
+
* récente en haut. Les cinq dernières minutes occupent la plus grande part de la hauteur,
|
|
1360
|
+
* le reste de la fenêtre détaillée est resserré. Au-delà de la fenêtre, les boîtes sont
|
|
1361
|
+
* empilées en liste sous leur agent.
|
|
1362
|
+
*/
|
|
1363
|
+
const LINKS_WINDOW_KEY = "messenger.links.window";
|
|
1364
|
+
const LINKS_WINDOWS = [5, 10, 15, 30, 60, 120, 240];
|
|
1365
|
+
const ZOOM_MINUTES = 5;
|
|
1366
|
+
const MAP = {
|
|
1367
|
+
axis: 92,
|
|
1368
|
+
lane: 180,
|
|
1369
|
+
gap: 12,
|
|
1370
|
+
head: 56,
|
|
1371
|
+
card: 46,
|
|
1372
|
+
cardGap: 4,
|
|
1373
|
+
/** Hauteur d'une minute dans la zone agrandie. */
|
|
1374
|
+
zoomPx: 64,
|
|
1375
|
+
/** Hauteur de la zone resserrée, quelle que soit sa durée. */
|
|
1376
|
+
restPx: 200,
|
|
1377
|
+
oldRow: 30,
|
|
1378
|
+
oldShown: 5,
|
|
1379
|
+
/** Boîtes plus anciennes que la fenêtre proposées dans le volet avant « Afficher ». */
|
|
1380
|
+
oldCandidates: 8,
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
const linksView = {
|
|
1384
|
+
windowMinutes: 30,
|
|
1385
|
+
/** Adresse de la boîte choisie sur la carte. */
|
|
1386
|
+
selected: null,
|
|
1387
|
+
/** Boîte pour laquelle le volet est construit : ses champs de saisie survivent aux rafraîchissements. */
|
|
1388
|
+
panelFor: null,
|
|
1389
|
+
search: "",
|
|
1390
|
+
note: "",
|
|
1391
|
+
/** Colonnes dont la liste des boîtes anciennes est entièrement dépliée. */
|
|
1392
|
+
showOld: new Set(),
|
|
1393
|
+
showAllCandidates: false,
|
|
1394
|
+
/** Liaison dont la suppression attend son second clic. */
|
|
1395
|
+
armedUnlink: null,
|
|
1396
|
+
/** Glissé en cours depuis la poignée d'une boîte. */
|
|
1397
|
+
drag: null,
|
|
1398
|
+
/** Position de chaque boîte affichée sur la carte, par adresse. */
|
|
1399
|
+
rects: new Map(),
|
|
1400
|
+
error: "",
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
function loadLinksWindow() {
|
|
1404
|
+
let saved = null;
|
|
1405
|
+
try {
|
|
1406
|
+
saved = Number(window.localStorage.getItem(LINKS_WINDOW_KEY));
|
|
1407
|
+
} catch {
|
|
1408
|
+
saved = null;
|
|
1409
|
+
}
|
|
1410
|
+
if (LINKS_WINDOWS.includes(saved)) linksView.windowMinutes = saved;
|
|
1411
|
+
el.linksWindow.value = String(linksView.windowMinutes);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function saveLinksWindow() {
|
|
1415
|
+
try {
|
|
1416
|
+
window.localStorage.setItem(LINKS_WINDOW_KEY, String(linksView.windowMinutes));
|
|
1417
|
+
} catch {
|
|
1418
|
+
// Stockage refusé : le réglage vaut pour cette visite.
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** Heure à la seconde : deux boîtes ouvertes dans la même minute doivent se distinguer. */
|
|
1423
|
+
function formatClock(value) {
|
|
1424
|
+
const date = new Date(value);
|
|
1425
|
+
return Number.isNaN(date.getTime())
|
|
1426
|
+
? String(value)
|
|
1427
|
+
: date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/** Âge lisible : « 38 s », « 4 min », « 2 h 05 ». */
|
|
1431
|
+
function formatAge(ms) {
|
|
1432
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
1433
|
+
if (seconds < 60) return `${seconds} s`;
|
|
1434
|
+
const minutes = Math.floor(seconds / 60);
|
|
1435
|
+
if (minutes < 60) return `${minutes} min`;
|
|
1436
|
+
const hours = Math.floor(minutes / 60);
|
|
1437
|
+
return `${hours} h ${String(minutes % 60).padStart(2, "0")}`;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function formatOffset(minutes) {
|
|
1441
|
+
if (minutes < 60) return `−${minutes} min`;
|
|
1442
|
+
const hours = Math.floor(minutes / 60);
|
|
1443
|
+
const rest = minutes % 60;
|
|
1444
|
+
return rest ? `−${hours} h ${String(rest).padStart(2, "0")}` : `−${hours} h`;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/** Boîtes qu'une liaison peut relier : celles qu'une session a ouvertes. */
|
|
1448
|
+
function linkableBoxes() {
|
|
1449
|
+
return [...state.directory.values()].filter((box) => box.registered);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function ageOf(box, now) {
|
|
1453
|
+
return now - new Date(box.created_at).getTime();
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function isRecent(box, now) {
|
|
1457
|
+
return ageOf(box, now) <= linksView.windowMinutes * 60_000;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** Rang de création des boîtes de la fenêtre détaillée : 1 pour la plus récente. */
|
|
1461
|
+
function creationRanks(boxes, now) {
|
|
1462
|
+
const recent = boxes.filter((box) => isRecent(box, now)).sort(byCreation);
|
|
1463
|
+
return new Map(recent.map((box, index) => [box.id, index + 1]));
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function byCreation(a, b) {
|
|
1467
|
+
return a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : a.id < b.id ? -1 : 1;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** Adresses liées à chaque boîte. */
|
|
1471
|
+
function linkIndex() {
|
|
1472
|
+
const index = new Map();
|
|
1473
|
+
for (const link of state.links) {
|
|
1474
|
+
const [a, b] = link.mailboxes.map((end) => end.mailbox);
|
|
1475
|
+
if (!index.has(a)) index.set(a, new Set());
|
|
1476
|
+
if (!index.has(b)) index.set(b, new Set());
|
|
1477
|
+
index.get(a).add(b);
|
|
1478
|
+
index.get(b).add(a);
|
|
1479
|
+
}
|
|
1480
|
+
return index;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** Colonnes de la carte : les boîtes groupées par agent, l'agent le plus récent en tête. */
|
|
1484
|
+
function linkLanes(boxes) {
|
|
1485
|
+
const lanes = new Map();
|
|
1486
|
+
for (const box of boxes) {
|
|
1487
|
+
const key = box.agent_name || box.id;
|
|
1488
|
+
if (!lanes.has(key)) lanes.set(key, { key, name: box.agent_name || "agent sans nom", seed: key, boxes: [] });
|
|
1489
|
+
lanes.get(key).boxes.push(box);
|
|
1490
|
+
}
|
|
1491
|
+
const list = [...lanes.values()];
|
|
1492
|
+
for (const lane of list) lane.boxes.sort(byCreation);
|
|
1493
|
+
list.sort((a, b) => byCreation(a.boxes[0], b.boxes[0]));
|
|
1494
|
+
return list;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/** Nombre de boîtes de la fenêtre détaillée que rien ne relie encore. */
|
|
1498
|
+
function renderLinksBadge() {
|
|
1499
|
+
const now = Date.now();
|
|
1500
|
+
const linked = linkIndex();
|
|
1501
|
+
const waiting = linkableBoxes().filter((box) => isRecent(box, now) && !linked.has(box.id)).length;
|
|
1502
|
+
el.linksBadge.hidden = waiting === 0;
|
|
1503
|
+
el.linksBadge.textContent = String(waiting);
|
|
1504
|
+
el.linksBadge.title = `${plural(waiting, "boîte récente")} sans liaison`;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
async function refreshLinks() {
|
|
1508
|
+
try {
|
|
1509
|
+
const [overview, links] = await Promise.all([api("/admin/overview"), api("/admin/links")]);
|
|
1510
|
+
setLoggedIn(true);
|
|
1511
|
+
setStatus("ok", `connecté · ${window.location.host}`);
|
|
1512
|
+
renderAgents(overview);
|
|
1513
|
+
state.links = links.links;
|
|
1514
|
+
renderLinksView();
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
if (error.message === "unauthorized") {
|
|
1517
|
+
setStatus("idle", "authentification requise");
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
setStatus("ko", `erreur : ${error.message}`);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function renderLinksView() {
|
|
1525
|
+
renderLinksBadge();
|
|
1526
|
+
if (linksView.selected && !state.directory.has(linksView.selected)) linksView.selected = null;
|
|
1527
|
+
// Un rafraîchissement pendant un glissé effacerait le fil qu'on est en train de tirer.
|
|
1528
|
+
if (!linksView.drag) renderLinksMap();
|
|
1529
|
+
renderLinksPanel();
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function setView(view) {
|
|
1533
|
+
if (state.view === view) return;
|
|
1534
|
+
state.view = view;
|
|
1535
|
+
for (const tab of el.tabs.querySelectorAll(".tab")) {
|
|
1536
|
+
tab.classList.toggle("is-active", tab.dataset.view === view);
|
|
1537
|
+
}
|
|
1538
|
+
setLoggedIn(true);
|
|
1539
|
+
refresh({ keepThread: true });
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Carte ---------------------------------------------------------------------
|
|
1543
|
+
|
|
1544
|
+
function mapGeometry() {
|
|
1545
|
+
const minutes = linksView.windowMinutes;
|
|
1546
|
+
const zoomMinutes = Math.min(ZOOM_MINUTES, minutes);
|
|
1547
|
+
const nowY = MAP.head + MAP.card / 2 + 6;
|
|
1548
|
+
const zoomH = zoomMinutes * MAP.zoomPx;
|
|
1549
|
+
const restH = minutes > zoomMinutes ? MAP.restPx : 0;
|
|
1550
|
+
return { minutes, zoomMinutes, nowY, zoomH, restH, bottom: nowY + zoomH + restH };
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** Hauteur, sur la carte, d'une boîte ouverte il y a `ageMinutes`. */
|
|
1554
|
+
function yForAge(geometry, ageMinutes) {
|
|
1555
|
+
const { minutes, zoomMinutes, nowY, zoomH, restH } = geometry;
|
|
1556
|
+
if (ageMinutes <= zoomMinutes) return nowY + ageMinutes * MAP.zoomPx;
|
|
1557
|
+
return nowY + zoomH + ((ageMinutes - zoomMinutes) / (minutes - zoomMinutes)) * restH;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/** Graduations de la zone resserrée : quelques repères ronds, et la fin de la fenêtre. */
|
|
1561
|
+
function restTicks(geometry) {
|
|
1562
|
+
const { minutes, zoomMinutes } = geometry;
|
|
1563
|
+
if (minutes <= zoomMinutes) return [];
|
|
1564
|
+
const span = minutes - zoomMinutes;
|
|
1565
|
+
const step = [5, 10, 15, 30, 60].find((candidate) => span / candidate <= 4) || 60;
|
|
1566
|
+
const ticks = [];
|
|
1567
|
+
for (let m = Math.ceil((zoomMinutes + 1) / step) * step; m < minutes; m += step) ticks.push(m);
|
|
1568
|
+
ticks.push(minutes);
|
|
1569
|
+
return ticks;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function placed(element, x, y, width, height) {
|
|
1573
|
+
element.style.left = `${x}px`;
|
|
1574
|
+
element.style.top = `${y}px`;
|
|
1575
|
+
if (width !== undefined) element.style.width = `${width}px`;
|
|
1576
|
+
if (height !== undefined) element.style.height = `${height}px`;
|
|
1577
|
+
return element;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function renderLinksMap() {
|
|
1581
|
+
const now = Date.now();
|
|
1582
|
+
const geometry = mapGeometry();
|
|
1583
|
+
const boxes = linkableBoxes();
|
|
1584
|
+
const lanes = linkLanes(boxes);
|
|
1585
|
+
const ranks = creationRanks(boxes, now);
|
|
1586
|
+
const linked = linkIndex();
|
|
1587
|
+
const canvas = el.linksCanvas;
|
|
1588
|
+
linksView.rects = new Map();
|
|
1589
|
+
|
|
1590
|
+
if (lanes.length === 0) {
|
|
1591
|
+
const empty = node("div", "empty");
|
|
1592
|
+
empty.appendChild(node("span", "empty-title", "Aucune boîte"));
|
|
1593
|
+
empty.appendChild(document.createTextNode("Les boîtes apparaissent ici dès qu'une session se connecte."));
|
|
1594
|
+
canvas.replaceChildren(empty);
|
|
1595
|
+
canvas.style.width = "";
|
|
1596
|
+
canvas.style.height = "";
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const width = MAP.axis + lanes.length * (MAP.lane + MAP.gap);
|
|
1601
|
+
const fragment = document.createDocumentFragment();
|
|
1602
|
+
|
|
1603
|
+
// Colonnes, et ce qui les place : la position de chaque boîte est calculée ici, une fois.
|
|
1604
|
+
const laneLayouts = lanes.map((lane, index) => {
|
|
1605
|
+
const x = MAP.axis + index * (MAP.lane + MAP.gap);
|
|
1606
|
+
const recent = lane.boxes.filter((box) => isRecent(box, now));
|
|
1607
|
+
const old = lane.boxes.filter((box) => !isRecent(box, now));
|
|
1608
|
+
let previousBottom = MAP.head + 4;
|
|
1609
|
+
const cards = recent.map((box) => {
|
|
1610
|
+
const center = yForAge(geometry, ageOf(box, now) / 60_000);
|
|
1611
|
+
return { box, top: center - MAP.card / 2 };
|
|
1612
|
+
});
|
|
1613
|
+
// Deux boîtes trop proches dans le temps se chevaucheraient : la plus ancienne descend.
|
|
1614
|
+
for (const card of cards) {
|
|
1615
|
+
card.top = Math.max(card.top, previousBottom + MAP.cardGap);
|
|
1616
|
+
previousBottom = card.top + MAP.card;
|
|
1617
|
+
}
|
|
1618
|
+
return { lane, x, cards, old, cardsBottom: previousBottom };
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
// La marge laisse la place au libellé de la dernière graduation, écrit sous son trait.
|
|
1622
|
+
const timeBottom = Math.max(geometry.bottom + 32, ...laneLayouts.map((layout) => layout.cardsBottom + 12));
|
|
1623
|
+
const oldTop = timeBottom + 28;
|
|
1624
|
+
const oldRows = Math.max(
|
|
1625
|
+
1,
|
|
1626
|
+
...laneLayouts.map((layout) =>
|
|
1627
|
+
linksView.showOld.has(layout.lane.key) ? layout.old.length + 1 : Math.min(layout.old.length, MAP.oldShown) + 1
|
|
1628
|
+
)
|
|
1629
|
+
);
|
|
1630
|
+
const height = oldTop + oldRows * MAP.oldRow + 16;
|
|
1631
|
+
|
|
1632
|
+
for (const layout of laneLayouts) fragment.appendChild(laneColumn(layout, height, now));
|
|
1633
|
+
|
|
1634
|
+
fragment.appendChild(timeAxis(geometry, width, now));
|
|
1635
|
+
|
|
1636
|
+
const separator = placed(node("div", "links-separator"), 8, timeBottom + 8, width - 16);
|
|
1637
|
+
fragment.appendChild(separator);
|
|
1638
|
+
const oldLabel = placed(node("div", "links-axis-label links-axis-old"), 8, timeBottom + 14, MAP.axis - 16);
|
|
1639
|
+
oldLabel.appendChild(node("strong", null, `Plus de ${formatOffset(linksView.windowMinutes).slice(1)}`));
|
|
1640
|
+
oldLabel.appendChild(node("span", null, "listes empilées"));
|
|
1641
|
+
fragment.appendChild(oldLabel);
|
|
1642
|
+
|
|
1643
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
1644
|
+
svg.setAttribute("class", "links-wires");
|
|
1645
|
+
svg.setAttribute("width", String(width));
|
|
1646
|
+
svg.setAttribute("height", String(height));
|
|
1647
|
+
fragment.appendChild(svg);
|
|
1648
|
+
|
|
1649
|
+
for (const layout of laneLayouts) {
|
|
1650
|
+
for (const card of layout.cards) {
|
|
1651
|
+
fragment.appendChild(boxCard(card.box, layout.x + 10, card.top, ranks.get(card.box.id), linked, now));
|
|
1652
|
+
}
|
|
1653
|
+
fragment.appendChild(oldList(layout, oldTop, linked));
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
canvas.replaceChildren(fragment);
|
|
1657
|
+
canvas.style.width = `${width}px`;
|
|
1658
|
+
canvas.style.height = `${height}px`;
|
|
1659
|
+
drawWires(svg);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function laneColumn(layout, height, now) {
|
|
1663
|
+
const { lane, x } = layout;
|
|
1664
|
+
const column = placed(node("div", "links-lane"), x, 0, MAP.lane, height);
|
|
1665
|
+
const head = node("div", "links-lane-head");
|
|
1666
|
+
head.appendChild(avatarFor(lane.seed, lane.name, lane.name, null, false));
|
|
1667
|
+
const text = node("div", "links-lane-text");
|
|
1668
|
+
text.appendChild(node("div", "links-lane-name", lane.name));
|
|
1669
|
+
const age = ageOf(lane.boxes[0], now);
|
|
1670
|
+
const last = node("div", "links-lane-last", `dernière : il y a ${formatAge(age)}`);
|
|
1671
|
+
if (age <= ZOOM_MINUTES * 60_000) last.classList.add("is-fresh");
|
|
1672
|
+
text.appendChild(last);
|
|
1673
|
+
head.appendChild(text);
|
|
1674
|
+
head.title = `${lane.name} · ${plural(lane.boxes.length, "boîte")}`;
|
|
1675
|
+
column.appendChild(head);
|
|
1676
|
+
return column;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function timeAxis(geometry, width, now) {
|
|
1680
|
+
const axis = document.createDocumentFragment();
|
|
1681
|
+
axis.appendChild(placed(node("div", "links-zoom"), MAP.axis - 8, geometry.nowY, width - MAP.axis, geometry.zoomH));
|
|
1682
|
+
|
|
1683
|
+
const tick = (minutes, kind) => {
|
|
1684
|
+
const y = yForAge(geometry, minutes);
|
|
1685
|
+
axis.appendChild(placed(node("div", `links-tick links-tick--${kind}`), 8, y, width - 16));
|
|
1686
|
+
const label = placed(node("div", `links-axis-label links-axis-label--${kind}`), 8, y + 3, MAP.axis - 16);
|
|
1687
|
+
label.appendChild(node("strong", null, minutes === 0 ? "maintenant" : formatOffset(minutes)));
|
|
1688
|
+
if (kind !== "rest") label.appendChild(node("span", "mono", formatClock(now - minutes * 60_000)));
|
|
1689
|
+
axis.appendChild(label);
|
|
1690
|
+
};
|
|
1691
|
+
|
|
1692
|
+
for (let m = 0; m <= geometry.zoomMinutes; m += 1) tick(m, m === 0 ? "now" : "zoom");
|
|
1693
|
+
for (const m of restTicks(geometry)) tick(m, "rest");
|
|
1694
|
+
return axis;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function boxCard(box, x, y, rank, linked, now) {
|
|
1698
|
+
const classes = ["links-card"];
|
|
1699
|
+
const age = ageOf(box, now);
|
|
1700
|
+
if (linksView.selected === box.id) classes.push("is-selected");
|
|
1701
|
+
if (linksView.selected && linked.get(linksView.selected)?.has(box.id)) classes.push("is-linked");
|
|
1702
|
+
const card = placed(node("button", classes.join(" ")), x, y, MAP.lane - 20, MAP.card);
|
|
1703
|
+
card.type = "button";
|
|
1704
|
+
card.dataset.box = box.id;
|
|
1705
|
+
card.title = boxTitle(box.id);
|
|
1706
|
+
|
|
1707
|
+
card.appendChild(node("span", rank <= 3 ? "links-rank is-top" : "links-rank", String(rank)));
|
|
1708
|
+
const text = node("span", "links-card-text");
|
|
1709
|
+
const name = node("span", "links-card-name", box.display_name || box.id);
|
|
1710
|
+
if (!box.display_name) name.classList.add("mono", "is-absent");
|
|
1711
|
+
text.appendChild(name);
|
|
1712
|
+
const meta = node("span", "links-card-meta");
|
|
1713
|
+
meta.appendChild(node("span", "mono", formatClock(box.created_at)));
|
|
1714
|
+
const ago = node("span", "links-card-ago", formatAge(age));
|
|
1715
|
+
if (age <= ZOOM_MINUTES * 60_000) ago.classList.add("is-fresh");
|
|
1716
|
+
meta.appendChild(ago);
|
|
1717
|
+
text.appendChild(meta);
|
|
1718
|
+
card.appendChild(text);
|
|
1719
|
+
|
|
1720
|
+
const count = linked.get(box.id)?.size || 0;
|
|
1721
|
+
if (count > 0) {
|
|
1722
|
+
const badge = node("span", "links-card-count");
|
|
1723
|
+
badge.appendChild(icon("link"));
|
|
1724
|
+
badge.appendChild(document.createTextNode(String(count)));
|
|
1725
|
+
badge.title = plural(count, "liaison");
|
|
1726
|
+
card.appendChild(badge);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
card.appendChild(dragHandle(box));
|
|
1730
|
+
card.addEventListener("click", () => selectLinkBox(box.id));
|
|
1731
|
+
linksView.rects.set(box.id, { x, y, w: MAP.lane - 20, h: MAP.card });
|
|
1732
|
+
return card;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function oldList(layout, top, linked) {
|
|
1736
|
+
const { lane, x, old } = layout;
|
|
1737
|
+
const list = document.createDocumentFragment();
|
|
1738
|
+
const expanded = linksView.showOld.has(lane.key);
|
|
1739
|
+
const shown = expanded ? old : old.slice(0, MAP.oldShown);
|
|
1740
|
+
|
|
1741
|
+
if (old.length === 0) {
|
|
1742
|
+
list.appendChild(placed(node("div", "links-old-none", "aucune autre boîte"), x + 10, top, MAP.lane - 20, 24));
|
|
1743
|
+
return list;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
shown.forEach((box, index) => {
|
|
1747
|
+
const y = top + index * MAP.oldRow;
|
|
1748
|
+
const classes = ["links-old-row"];
|
|
1749
|
+
if (linksView.selected === box.id) classes.push("is-selected");
|
|
1750
|
+
if (linksView.selected && linked.get(linksView.selected)?.has(box.id)) classes.push("is-linked");
|
|
1751
|
+
const row = placed(node("button", classes.join(" ")), x + 10, y, MAP.lane - 20, MAP.oldRow - 4);
|
|
1752
|
+
row.type = "button";
|
|
1753
|
+
row.dataset.box = box.id;
|
|
1754
|
+
row.title = boxTitle(box.id);
|
|
1755
|
+
const name = node("span", "links-old-name", box.display_name || box.id);
|
|
1756
|
+
if (!box.display_name) name.classList.add("mono", "is-absent");
|
|
1757
|
+
row.appendChild(name);
|
|
1758
|
+
if (linked.get(box.id)?.size) row.appendChild(icon("link"));
|
|
1759
|
+
row.appendChild(node("span", "links-old-date", formatShort(box.created_at)));
|
|
1760
|
+
row.addEventListener("click", () => selectLinkBox(box.id));
|
|
1761
|
+
linksView.rects.set(box.id, { x: x + 10, y, w: MAP.lane - 20, h: MAP.oldRow - 4 });
|
|
1762
|
+
list.appendChild(row);
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
if (old.length > MAP.oldShown) {
|
|
1766
|
+
const more = expanded ? "Replier" : `+ ${old.length - MAP.oldShown} autres`;
|
|
1767
|
+
const toggle = placed(
|
|
1768
|
+
button("links-old-more", more, () => {
|
|
1769
|
+
if (expanded) linksView.showOld.delete(lane.key);
|
|
1770
|
+
else linksView.showOld.add(lane.key);
|
|
1771
|
+
renderLinksMap();
|
|
1772
|
+
}),
|
|
1773
|
+
x + 10,
|
|
1774
|
+
top + shown.length * MAP.oldRow,
|
|
1775
|
+
MAP.lane - 20
|
|
1776
|
+
);
|
|
1777
|
+
list.appendChild(toggle);
|
|
1778
|
+
}
|
|
1779
|
+
return list;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/** Courbe d'une boîte à l'autre : des bords qui se font face, ou par la droite dans une même colonne. */
|
|
1783
|
+
function wirePath(from, to) {
|
|
1784
|
+
const ay = from.y + from.h / 2;
|
|
1785
|
+
const by = to.y + to.h / 2;
|
|
1786
|
+
if (from.x + from.w <= to.x || to.x + to.w <= from.x) {
|
|
1787
|
+
const [left, right, ly, ry] = from.x < to.x ? [from, to, ay, by] : [to, from, by, ay];
|
|
1788
|
+
const x1 = left.x + left.w;
|
|
1789
|
+
const x2 = right.x;
|
|
1790
|
+
const dx = Math.max(24, (x2 - x1) / 2);
|
|
1791
|
+
return `M${x1} ${ly} C${x1 + dx} ${ly} ${x2 - dx} ${ry} ${x2} ${ry}`;
|
|
1792
|
+
}
|
|
1793
|
+
const x = from.x + from.w;
|
|
1794
|
+
return `M${x} ${ay} C${x + 40} ${ay} ${x + 40} ${by} ${x} ${by}`;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function wire(svg, d, className) {
|
|
1798
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
1799
|
+
path.setAttribute("d", d);
|
|
1800
|
+
path.setAttribute("class", className);
|
|
1801
|
+
svg.appendChild(path);
|
|
1802
|
+
return path;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function drawWires(svg) {
|
|
1806
|
+
for (const link of state.links) {
|
|
1807
|
+
const [a, b] = link.mailboxes.map((end) => end.mailbox);
|
|
1808
|
+
const from = linksView.rects.get(a);
|
|
1809
|
+
const to = linksView.rects.get(b);
|
|
1810
|
+
if (!from || !to) continue;
|
|
1811
|
+
const active = linksView.selected === a || linksView.selected === b;
|
|
1812
|
+
wire(svg, wirePath(from, to), active ? "links-wire is-active" : "links-wire");
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/** Poignée d'une boîte : la tirer jusqu'à une autre boîte les lie. */
|
|
1817
|
+
function dragHandle(box) {
|
|
1818
|
+
const handle = node("span", "links-handle");
|
|
1819
|
+
handle.title = "Tirer jusqu'à une autre boîte pour les lier";
|
|
1820
|
+
handle.addEventListener("click", (event) => event.stopPropagation());
|
|
1821
|
+
handle.addEventListener("pointerdown", (event) => {
|
|
1822
|
+
event.preventDefault();
|
|
1823
|
+
event.stopPropagation();
|
|
1824
|
+
startLinkDrag(event, box.id, handle);
|
|
1825
|
+
});
|
|
1826
|
+
return handle;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function canvasPoint(event) {
|
|
1830
|
+
const rect = el.linksCanvas.getBoundingClientRect();
|
|
1831
|
+
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function startLinkDrag(event, boxId, handle) {
|
|
1835
|
+
const svg = el.linksCanvas.querySelector(".links-wires");
|
|
1836
|
+
const from = linksView.rects.get(boxId);
|
|
1837
|
+
if (!svg || !from) return;
|
|
1838
|
+
const start = { x: from.x + from.w, y: from.y + from.h / 2 };
|
|
1839
|
+
const path = wire(svg, "", "links-wire is-drawing");
|
|
1840
|
+
linksView.drag = { from: boxId };
|
|
1841
|
+
el.links.classList.add("is-linking");
|
|
1842
|
+
handle.setPointerCapture?.(event.pointerId);
|
|
1843
|
+
|
|
1844
|
+
const onMove = (move) => {
|
|
1845
|
+
const point = canvasPoint(move);
|
|
1846
|
+
const dx = Math.max(24, Math.abs(point.x - start.x) / 2);
|
|
1847
|
+
path.setAttribute("d", `M${start.x} ${start.y} C${start.x + dx} ${start.y} ${point.x - dx} ${point.y} ${point.x} ${point.y}`);
|
|
1848
|
+
};
|
|
1849
|
+
const onUp = (up) => {
|
|
1850
|
+
window.removeEventListener("pointermove", onMove);
|
|
1851
|
+
window.removeEventListener("pointerup", onUp);
|
|
1852
|
+
el.links.classList.remove("is-linking");
|
|
1853
|
+
linksView.drag = null;
|
|
1854
|
+
const target = document.elementFromPoint(up.clientX, up.clientY)?.closest("[data-box]");
|
|
1855
|
+
const to = target?.dataset.box;
|
|
1856
|
+
if (to && to !== boxId) createLink(boxId, to);
|
|
1857
|
+
else renderLinksMap();
|
|
1858
|
+
};
|
|
1859
|
+
window.addEventListener("pointermove", onMove);
|
|
1860
|
+
window.addEventListener("pointerup", onUp);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
function selectLinkBox(boxId) {
|
|
1864
|
+
linksView.selected = linksView.selected === boxId ? null : boxId;
|
|
1865
|
+
linksView.armedUnlink = null;
|
|
1866
|
+
linksView.error = "";
|
|
1867
|
+
renderLinksMap();
|
|
1868
|
+
renderLinksPanel();
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
async function createLink(first, second) {
|
|
1872
|
+
linksView.error = "";
|
|
1873
|
+
try {
|
|
1874
|
+
await post("/admin/links", { mailboxes: [first, second], note: linksView.note.trim() || null });
|
|
1875
|
+
linksView.note = "";
|
|
1876
|
+
const note = el.linksPanel.querySelector("#links-note");
|
|
1877
|
+
if (note) note.value = "";
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
linksView.error = error.message;
|
|
1880
|
+
}
|
|
1881
|
+
await refreshLinks();
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
async function deleteLink(linkId) {
|
|
1885
|
+
if (linksView.armedUnlink !== linkId) {
|
|
1886
|
+
linksView.armedUnlink = linkId;
|
|
1887
|
+
renderLinksPanel();
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
linksView.armedUnlink = null;
|
|
1891
|
+
linksView.error = "";
|
|
1892
|
+
try {
|
|
1893
|
+
await remove(`/admin/links/${encodeURIComponent(linkId)}`);
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
linksView.error = error.message;
|
|
1896
|
+
}
|
|
1897
|
+
await refreshLinks();
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// Volet ---------------------------------------------------------------------
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* Volet de la boîte choisie. Il n'est reconstruit qu'au changement de boîte : ses champs
|
|
1904
|
+
* de saisie gardent leur contenu et le focus pendant les rafraîchissements, seules les
|
|
1905
|
+
* listes sont redessinées.
|
|
1906
|
+
*/
|
|
1907
|
+
function renderLinksPanel() {
|
|
1908
|
+
const id = linksView.selected;
|
|
1909
|
+
if (!id) {
|
|
1910
|
+
linksView.panelFor = null;
|
|
1911
|
+
const empty = node("div", "empty");
|
|
1912
|
+
empty.appendChild(node("span", "empty-title", "Aucune boîte choisie"));
|
|
1913
|
+
empty.appendChild(document.createTextNode("Choisissez une boîte sur la carte pour voir ses liaisons et en créer."));
|
|
1914
|
+
el.linksPanel.replaceChildren(empty, deliveryNote());
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (linksView.panelFor !== id) buildLinksPanel(id);
|
|
1918
|
+
fillLinksPanel(id);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function buildLinksPanel(id) {
|
|
1922
|
+
linksView.panelFor = id;
|
|
1923
|
+
linksView.search = "";
|
|
1924
|
+
linksView.showAllCandidates = false;
|
|
1925
|
+
const panel = document.createDocumentFragment();
|
|
1926
|
+
|
|
1927
|
+
const head = node("div", "links-panel-head");
|
|
1928
|
+
head.appendChild(node("span", "section-label", "Boîte sélectionnée"));
|
|
1929
|
+
const close = node("button", "btn btn--icon btn--ghost");
|
|
1930
|
+
close.type = "button";
|
|
1931
|
+
close.title = "Fermer";
|
|
1932
|
+
close.setAttribute("aria-label", "Désélectionner la boîte");
|
|
1933
|
+
close.appendChild(icon("close"));
|
|
1934
|
+
close.addEventListener("click", () => selectLinkBox(id));
|
|
1935
|
+
head.appendChild(close);
|
|
1936
|
+
panel.appendChild(head);
|
|
1937
|
+
|
|
1938
|
+
panel.appendChild(node("div", "links-panel-box"));
|
|
1939
|
+
|
|
1940
|
+
const linksSection = node("div", "links-panel-section");
|
|
1941
|
+
linksSection.appendChild(node("h3", null, "Liaisons"));
|
|
1942
|
+
linksSection.appendChild(node("ul", "links-panel-links"));
|
|
1943
|
+
panel.appendChild(linksSection);
|
|
1944
|
+
|
|
1945
|
+
const linkTo = node("div", "links-panel-section");
|
|
1946
|
+
const title = node("div", "links-panel-title");
|
|
1947
|
+
title.appendChild(node("h3", null, "Lier à…"));
|
|
1948
|
+
title.appendChild(node("span", "muted", "rang de création"));
|
|
1949
|
+
linkTo.appendChild(title);
|
|
1950
|
+
|
|
1951
|
+
const note = document.createElement("input");
|
|
1952
|
+
note.type = "text";
|
|
1953
|
+
note.id = "links-note";
|
|
1954
|
+
note.className = "links-input";
|
|
1955
|
+
note.maxLength = 500;
|
|
1956
|
+
note.placeholder = "Motif transmis aux deux agents (facultatif)";
|
|
1957
|
+
note.value = linksView.note;
|
|
1958
|
+
note.addEventListener("input", () => {
|
|
1959
|
+
linksView.note = note.value;
|
|
1960
|
+
});
|
|
1961
|
+
linkTo.appendChild(note);
|
|
1962
|
+
|
|
1963
|
+
const search = node("label", "links-search");
|
|
1964
|
+
search.appendChild(icon("search"));
|
|
1965
|
+
const input = document.createElement("input");
|
|
1966
|
+
input.type = "search";
|
|
1967
|
+
input.placeholder = "Agent ou nom de boîte…";
|
|
1968
|
+
input.addEventListener("input", () => {
|
|
1969
|
+
linksView.search = input.value.trim().toLowerCase();
|
|
1970
|
+
fillLinksPanel(id);
|
|
1971
|
+
});
|
|
1972
|
+
search.appendChild(input);
|
|
1973
|
+
linkTo.appendChild(search);
|
|
1974
|
+
|
|
1975
|
+
linkTo.appendChild(node("p", "links-error"));
|
|
1976
|
+
linkTo.appendChild(node("ul", "links-candidates"));
|
|
1977
|
+
panel.appendChild(linkTo);
|
|
1978
|
+
panel.appendChild(deliveryNote());
|
|
1979
|
+
|
|
1980
|
+
el.linksPanel.replaceChildren(panel);
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function deliveryNote() {
|
|
1984
|
+
const note = node("p", "links-delivery");
|
|
1985
|
+
note.appendChild(icon("info"));
|
|
1986
|
+
note.appendChild(
|
|
1987
|
+
document.createTextNode(
|
|
1988
|
+
"Chaque agent reçoit la liaison à sa prochaine connexion (whoami), ou aussitôt si ses notifications sont armées."
|
|
1989
|
+
)
|
|
1990
|
+
);
|
|
1991
|
+
return note;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
function fillLinksPanel(id) {
|
|
1995
|
+
const box = state.directory.get(id);
|
|
1996
|
+
const now = Date.now();
|
|
1997
|
+
const boxes = linkableBoxes();
|
|
1998
|
+
const ranks = creationRanks(boxes, now);
|
|
1999
|
+
|
|
2000
|
+
const summary = el.linksPanel.querySelector(".links-panel-box");
|
|
2001
|
+
summary.replaceChildren();
|
|
2002
|
+
const identity = node("div", "links-panel-identity");
|
|
2003
|
+
identity.appendChild(agentAvatar(id, "avatar--lg"));
|
|
2004
|
+
const names = node("div", "agent-text");
|
|
2005
|
+
names.appendChild(node("div", "links-panel-agent", agentLabel(id)));
|
|
2006
|
+
const address = node("div", "party-addr");
|
|
2007
|
+
address.appendChild(node("span", null, box.display_name || "boîte sans nom"));
|
|
2008
|
+
address.appendChild(node("span", "mono", `· ${id}`));
|
|
2009
|
+
address.appendChild(copyButton(id, "Copier l'adresse"));
|
|
2010
|
+
names.appendChild(address);
|
|
2011
|
+
identity.appendChild(names);
|
|
2012
|
+
summary.appendChild(identity);
|
|
2013
|
+
const rank = ranks.get(id);
|
|
2014
|
+
summary.appendChild(
|
|
2015
|
+
node(
|
|
2016
|
+
"p",
|
|
2017
|
+
"links-panel-meta",
|
|
2018
|
+
[`Créée à ${formatClock(box.created_at)}`, `il y a ${formatAge(ageOf(box, now))}`, rank ? `rang #${rank}` : null]
|
|
2019
|
+
.filter(Boolean)
|
|
2020
|
+
.join(" · ")
|
|
2021
|
+
)
|
|
2022
|
+
);
|
|
2023
|
+
if (box.description) summary.appendChild(node("p", "links-panel-meta", box.description));
|
|
2024
|
+
|
|
2025
|
+
const own = state.links.filter((link) => link.mailboxes.some((end) => end.mailbox === id));
|
|
2026
|
+
const list = el.linksPanel.querySelector(".links-panel-links");
|
|
2027
|
+
list.replaceChildren();
|
|
2028
|
+
if (own.length === 0) list.appendChild(node("li", "links-panel-none", "Aucune liaison."));
|
|
2029
|
+
for (const link of own) list.appendChild(linkRow(link, id));
|
|
2030
|
+
|
|
2031
|
+
el.linksPanel.querySelector(".links-error").textContent = linksView.error;
|
|
2032
|
+
|
|
2033
|
+
const linked = linkIndex().get(id) || new Set();
|
|
2034
|
+
const search = linksView.search;
|
|
2035
|
+
const candidates = boxes
|
|
2036
|
+
.filter((other) => other.id !== id && !linked.has(other.id))
|
|
2037
|
+
.filter(
|
|
2038
|
+
(other) =>
|
|
2039
|
+
!search ||
|
|
2040
|
+
[other.agent_name, other.display_name, other.id].some((value) => (value || "").toLowerCase().includes(search))
|
|
2041
|
+
)
|
|
2042
|
+
.sort(byCreation);
|
|
2043
|
+
const recent = candidates.filter((other) => isRecent(other, now));
|
|
2044
|
+
const old = candidates.filter((other) => !isRecent(other, now));
|
|
2045
|
+
|
|
2046
|
+
const container = el.linksPanel.querySelector(".links-candidates");
|
|
2047
|
+
container.replaceChildren();
|
|
2048
|
+
if (candidates.length === 0) {
|
|
2049
|
+
container.appendChild(node("li", "links-panel-none", search ? "Aucune boîte ne correspond." : "Aucune autre boîte à lier."));
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
for (const other of recent) container.appendChild(candidateRow(id, other, ranks.get(other.id), now));
|
|
2053
|
+
if (old.length > 0) {
|
|
2054
|
+
container.appendChild(
|
|
2055
|
+
node("li", "links-candidates-sep", `Plus de ${formatOffset(linksView.windowMinutes).slice(1)}`)
|
|
2056
|
+
);
|
|
2057
|
+
const shown = linksView.showAllCandidates ? old : old.slice(0, MAP.oldCandidates);
|
|
2058
|
+
for (const other of shown) container.appendChild(candidateRow(id, other, null, now));
|
|
2059
|
+
if (shown.length < old.length) {
|
|
2060
|
+
const item = node("li");
|
|
2061
|
+
item.appendChild(
|
|
2062
|
+
button("btn btn--link", `Afficher les ${old.length - shown.length} autres`, () => {
|
|
2063
|
+
linksView.showAllCandidates = true;
|
|
2064
|
+
fillLinksPanel(id);
|
|
2065
|
+
})
|
|
2066
|
+
);
|
|
2067
|
+
container.appendChild(item);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
function linkRow(link, selfId) {
|
|
2073
|
+
const item = node("li", "links-panel-link");
|
|
2074
|
+
const otherId = link.mailboxes.find((end) => end.mailbox !== selfId).mailbox;
|
|
2075
|
+
const other = state.directory.get(otherId);
|
|
2076
|
+
|
|
2077
|
+
const top = node("div", "links-panel-link-top");
|
|
2078
|
+
top.appendChild(agentAvatar(otherId, "avatar--sm"));
|
|
2079
|
+
const text = node("div", "agent-text");
|
|
2080
|
+
text.appendChild(node("div", "agent-name", agentLabel(otherId)));
|
|
2081
|
+
text.appendChild(node("div", "agent-box", other ? other.display_name || otherId : otherId));
|
|
2082
|
+
top.appendChild(text);
|
|
2083
|
+
|
|
2084
|
+
const armed = linksView.armedUnlink === link.link_id;
|
|
2085
|
+
const unlink = node("button", armed ? "btn btn--danger links-unlink" : "btn btn--icon btn--ghost links-unlink");
|
|
2086
|
+
unlink.type = "button";
|
|
2087
|
+
unlink.title = armed ? "Confirmer la suppression de la liaison" : "Supprimer la liaison";
|
|
2088
|
+
unlink.setAttribute("aria-label", unlink.title);
|
|
2089
|
+
if (armed) unlink.textContent = "Confirmer";
|
|
2090
|
+
else unlink.appendChild(icon("unlink"));
|
|
2091
|
+
unlink.addEventListener("click", () => deleteLink(link.link_id));
|
|
2092
|
+
top.appendChild(unlink);
|
|
2093
|
+
item.appendChild(top);
|
|
2094
|
+
|
|
2095
|
+
if (link.note) item.appendChild(node("p", "links-panel-note", link.note));
|
|
2096
|
+
|
|
2097
|
+
const status = node("div", "links-panel-status");
|
|
2098
|
+
status.appendChild(node("span", "muted", `liée à ${formatClock(link.created_at)}`));
|
|
2099
|
+
for (const end of link.mailboxes) {
|
|
2100
|
+
const who = agentLabel(end.mailbox);
|
|
2101
|
+
status.appendChild(
|
|
2102
|
+
end.seen_at ? tag("tag tag--ok", `reçue par ${who}`, "check") : tag("tag tag--warn", `en attente : ${who}`, "clock")
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
item.appendChild(status);
|
|
2106
|
+
return item;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
function candidateRow(selfId, other, rank, now) {
|
|
2110
|
+
const item = node("li", "links-candidate");
|
|
2111
|
+
if (rank) item.appendChild(node("span", rank <= 3 ? "links-rank is-top" : "links-rank", String(rank)));
|
|
2112
|
+
else item.appendChild(node("span", "links-rank is-old", "·"));
|
|
2113
|
+
item.appendChild(agentAvatar(other.id, "avatar--sm"));
|
|
2114
|
+
const text = node("div", "agent-text");
|
|
2115
|
+
text.appendChild(node("div", "agent-name", agentLabel(other.id)));
|
|
2116
|
+
const age = ageOf(other, now);
|
|
2117
|
+
const meta = node(
|
|
2118
|
+
"div",
|
|
2119
|
+
"agent-box",
|
|
2120
|
+
rank
|
|
2121
|
+
? `${other.display_name || shortAddress(other.id)} · ${formatClock(other.created_at)} · ${formatAge(age)}`
|
|
2122
|
+
: `${other.display_name || shortAddress(other.id)} · ${formatShort(other.created_at)}`
|
|
2123
|
+
);
|
|
2124
|
+
if (rank && age <= ZOOM_MINUTES * 60_000) meta.classList.add("is-fresh");
|
|
2125
|
+
text.appendChild(meta);
|
|
2126
|
+
item.appendChild(text);
|
|
2127
|
+
item.title = boxTitle(other.id);
|
|
2128
|
+
item.appendChild(button("btn links-link", "Lier", () => createLink(selfId, other.id)));
|
|
2129
|
+
return item;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
1296
2132
|
// --- Purge des boîtes inactives --------------------------------------------
|
|
1297
2133
|
|
|
1298
2134
|
/**
|
|
@@ -1519,6 +2355,24 @@ el.purgeConfirm.addEventListener("click", confirmPurge);
|
|
|
1519
2355
|
|
|
1520
2356
|
el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
|
|
1521
2357
|
|
|
2358
|
+
el.tabs.addEventListener("click", (event) => {
|
|
2359
|
+
const tab = event.target.closest(".tab");
|
|
2360
|
+
if (tab) setView(tab.dataset.view);
|
|
2361
|
+
});
|
|
2362
|
+
|
|
2363
|
+
el.linksWindow.addEventListener("change", () => {
|
|
2364
|
+
linksView.windowMinutes = Number(el.linksWindow.value);
|
|
2365
|
+
saveLinksWindow();
|
|
2366
|
+
renderLinksView();
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
// Échap relâche la boîte choisie sur la carte, sauf depuis un champ du volet.
|
|
2370
|
+
document.addEventListener("keydown", (event) => {
|
|
2371
|
+
if (event.key !== "Escape" || state.view !== "links" || !linksView.selected) return;
|
|
2372
|
+
if (event.target.closest && event.target.closest("input, select, dialog")) return;
|
|
2373
|
+
selectLinkBox(linksView.selected);
|
|
2374
|
+
});
|
|
2375
|
+
|
|
1522
2376
|
// Le bouton reste en place une fois la colonne repliée : il est le seul moyen de la
|
|
1523
2377
|
// rouvrir. Seul ce qu'il annonce change.
|
|
1524
2378
|
el.collapse.addEventListener("click", () => {
|
|
@@ -1589,6 +2443,11 @@ el.logout.addEventListener("click", async () => {
|
|
|
1589
2443
|
state.openMessages = new Set();
|
|
1590
2444
|
state.directory = new Map();
|
|
1591
2445
|
state.groups = [];
|
|
2446
|
+
state.links = [];
|
|
2447
|
+
linksView.selected = null;
|
|
2448
|
+
linksView.panelFor = null;
|
|
2449
|
+
el.linksCanvas.replaceChildren();
|
|
2450
|
+
el.linksPanel.replaceChildren();
|
|
1592
2451
|
el.agents.replaceChildren();
|
|
1593
2452
|
el.threads.replaceChildren();
|
|
1594
2453
|
renderEmptyReader();
|
|
@@ -1600,5 +2459,6 @@ setInterval(() => {
|
|
|
1600
2459
|
}, REFRESH_MS);
|
|
1601
2460
|
|
|
1602
2461
|
el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
|
|
2462
|
+
loadLinksWindow();
|
|
1603
2463
|
renderEmptyReader();
|
|
1604
2464
|
refresh();
|