@imenam/mcp-messenger 1.0.30 → 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 -727
- 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 +5 -1
- package/dist/src/client/messenger-client.js +5 -0
- package/dist/src/core/store.d.ts +23 -1
- package/dist/src/core/store.js +305 -211
- package/dist/src/core/types.d.ts +24 -0
- package/dist/src/mcp/start.js +25 -22
- package/dist/src/mcp/tools.js +45 -7
- package/dist/src/server/app.js +41 -10
- package/dist/src/server/ui/app.js +839 -7
- package/dist/src/server/ui/index.html +59 -0
- package/dist/src/server/ui/styles.css +687 -0
- 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,
|
|
@@ -29,9 +29,22 @@ const ICONS = {
|
|
|
29
29
|
info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 11v5", "M12 8h.01"],
|
|
30
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"],
|
|
31
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"],
|
|
32
41
|
};
|
|
33
42
|
|
|
34
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: [],
|
|
35
48
|
/**
|
|
36
49
|
* Ce sur quoi la liste des fils est filtrée.
|
|
37
50
|
*
|
|
@@ -108,6 +121,13 @@ const el = {
|
|
|
108
121
|
purgePreviewEmpty: document.getElementById("purge-preview-empty"),
|
|
109
122
|
purgeResult: document.getElementById("purge-result"),
|
|
110
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"),
|
|
111
131
|
};
|
|
112
132
|
|
|
113
133
|
// --- Fabrique de noeuds ----------------------------------------------------
|
|
@@ -361,9 +381,12 @@ async function action(method, path, body) {
|
|
|
361
381
|
|
|
362
382
|
function setLoggedIn(loggedIn) {
|
|
363
383
|
el.login.hidden = loggedIn;
|
|
364
|
-
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;
|
|
365
387
|
el.controls.hidden = !loggedIn;
|
|
366
|
-
|
|
388
|
+
// La recherche porte sur les fils ; le volet des liaisons a la sienne.
|
|
389
|
+
el.searchBox.hidden = !loggedIn || state.view !== "threads";
|
|
367
390
|
if (!loggedIn) {
|
|
368
391
|
setStatus("idle", "non connecté");
|
|
369
392
|
el.token.focus();
|
|
@@ -1187,8 +1210,12 @@ async function refreshThread() {
|
|
|
1187
1210
|
// --- Boucle de rafraîchissement --------------------------------------------
|
|
1188
1211
|
|
|
1189
1212
|
async function refresh(options) {
|
|
1213
|
+
if (state.view === "links") {
|
|
1214
|
+
await refreshLinks();
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1190
1217
|
try {
|
|
1191
|
-
const [overview, list] = await Promise.all([
|
|
1218
|
+
const [overview, list, links] = await Promise.all([
|
|
1192
1219
|
api("/admin/overview"),
|
|
1193
1220
|
api("/admin/threads", {
|
|
1194
1221
|
// Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
|
|
@@ -1198,10 +1225,13 @@ async function refresh(options) {
|
|
|
1198
1225
|
priority: state.filter === "high" ? "high" : undefined,
|
|
1199
1226
|
limit: 100,
|
|
1200
1227
|
}),
|
|
1228
|
+
api("/admin/links"),
|
|
1201
1229
|
]);
|
|
1202
1230
|
setLoggedIn(true);
|
|
1203
1231
|
setStatus("ok", `connecté · ${window.location.host}`);
|
|
1204
1232
|
renderAgents(overview);
|
|
1233
|
+
state.links = links.links;
|
|
1234
|
+
renderLinksBadge();
|
|
1205
1235
|
renderThreads(list.threads);
|
|
1206
1236
|
if (!options || !options.keepThread) {
|
|
1207
1237
|
if (state.threadId) await refreshThread();
|
|
@@ -1321,6 +1351,784 @@ for (const id of Object.keys(PANELS)) {
|
|
|
1321
1351
|
|
|
1322
1352
|
loadLayout();
|
|
1323
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
|
+
|
|
1324
2132
|
// --- Purge des boîtes inactives --------------------------------------------
|
|
1325
2133
|
|
|
1326
2134
|
/**
|
|
@@ -1547,6 +2355,24 @@ el.purgeConfirm.addEventListener("click", confirmPurge);
|
|
|
1547
2355
|
|
|
1548
2356
|
el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
|
|
1549
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
|
+
|
|
1550
2376
|
// Le bouton reste en place une fois la colonne repliée : il est le seul moyen de la
|
|
1551
2377
|
// rouvrir. Seul ce qu'il annonce change.
|
|
1552
2378
|
el.collapse.addEventListener("click", () => {
|
|
@@ -1617,6 +2443,11 @@ el.logout.addEventListener("click", async () => {
|
|
|
1617
2443
|
state.openMessages = new Set();
|
|
1618
2444
|
state.directory = new Map();
|
|
1619
2445
|
state.groups = [];
|
|
2446
|
+
state.links = [];
|
|
2447
|
+
linksView.selected = null;
|
|
2448
|
+
linksView.panelFor = null;
|
|
2449
|
+
el.linksCanvas.replaceChildren();
|
|
2450
|
+
el.linksPanel.replaceChildren();
|
|
1620
2451
|
el.agents.replaceChildren();
|
|
1621
2452
|
el.threads.replaceChildren();
|
|
1622
2453
|
renderEmptyReader();
|
|
@@ -1628,5 +2459,6 @@ setInterval(() => {
|
|
|
1628
2459
|
}, REFRESH_MS);
|
|
1629
2460
|
|
|
1630
2461
|
el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
|
|
2462
|
+
loadLinksWindow();
|
|
1631
2463
|
renderEmptyReader();
|
|
1632
2464
|
refresh();
|