Compare commits

...
Author SHA1 Message Date
bruno 3e96ebf3c5 Fusion #69 : Alertes unifiees (tests/frontend/draco-alertes.test.mjs)
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 12:13:23 -04:00
bruno f88c05a053 Fusion #69 : Alertes unifiees (frontend/draco-alertes.css) 2026-10-28 12:13:20 -04:00
bruno 29ce94808d Fusion #69 : Alertes unifiees (frontend/draco-alertes.js) 2026-10-28 12:13:17 -04:00
bruno 6f31275c12 Fusion #69 : Alertes unifiees (backend/main.py) 2026-10-28 12:13:15 -04:00
bruno f11dc26930 Fusion #69 : Alertes unifiees (frontend/index.html) 2026-10-28 12:13:11 -04:00
bruno 3b8402374a Bump 2.81.3 (CHANGELOG.md)
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:39:13 -04:00
bruno 6635defb2e Bump 2.81.3 (docs/ROADMAP.md)
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:39:09 -04:00
bruno 7fb073d462 Bump 2.81.3 (desktop/Cargo.lock)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:39:06 -04:00
bruno 2e59f8786b Bump 2.81.3 (desktop/Cargo.toml)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:39:03 -04:00
bruno ba6069962a Bump 2.81.3 (desktop/tauri.conf.json)
CI / lint (push) Failing after 0s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:39:00 -04:00
bruno 375de59e28 Bump 2.81.3 (package.json)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 10:38:57 -04:00
bruno 3b84a86b8f Bump 2.81.3 (VERSION)
CI / lint (push) Failing after 0s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:38:54 -04:00
bruno a5dac2349d Merge pull request 'barre-apps v0.1.6 : vendange (dérivation -dev, avatar compte) — menu natif préservé' (#37) from barre-apps-v0.1.6-obsigate into main
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m59s
2026-10-28 10:34:21 -04:00
bruno da6de52198 barre-apps v0.1.6 : CSS vendange (menus, avatar bouton)
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Failing after 0s
2026-10-28 10:32:06 -04:00
bruno 87016e5581 barre-apps v0.1.6 : JS vendange (derivation -dev, menus) 2026-10-28 10:32:01 -04:00
bruno e0e45ac048 barre-apps v0.1.6 : fragment inline (avatar bouton compte, aria menu) - menu natif et DRACO_URLS inchanges 2026-10-28 10:31:55 -04:00
bruno 1b3ba04b5b chore(release): v2.81.2 — CHANGELOG
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:42 -04:00
bruno 5c5180336b chore(release): v2.81.2 — synchroniser docs/ROADMAP.md
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:40 -04:00
bruno 73bf863e40 chore(release): v2.81.2 — synchroniser desktop/Cargo.lock
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:37 -04:00
bruno 06d0e354fe chore(release): v2.81.2 — synchroniser desktop/Cargo.toml
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:32 -04:00
bruno a01fba6602 chore(release): v2.81.2 — synchroniser desktop/tauri.conf.json
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:29 -04:00
bruno 54ebf2365c chore(release): v2.81.2 — synchroniser package.json
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:26 -04:00
bruno 24eaa7f9df chore(release): v2.81.2 (panneau Spark / console, PR #35)
CI / security (push) Successful in 2m0s
CI / lint (push) Successful in 3m12s
CI / test (push) Failing after 0s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:24 -04:00
bruno 8e3eb3a9d7 Merge pull request 'fix(sidebar): style console, statut dans le titre, composer console, synchro domaine' (#35) from fix/sidebar-console-style-domaine into main
CI / security (push) Failing after 0s
CI / lint (push) Successful in 3m11s
CI / test (push) Failing after 0s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:28:50 -04:00
bruno 01ce8b8ec3 Merge pull request 'chore(release): version 2.81.1 (correctifs CI + icone Secrets)' (#36) from fix/version-2.81.1 into main
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 2m7s
2026-10-28 09:19:57 -04:00
bruno dcec510451 fix(sidebar): CSS composer console, statut dans le titre, pied épuré
CI / lint (pull_request) Failing after 1s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 08:49:05 -04:00
bruno cb468b0f3c fix(sidebar): style console, statut dans le titre, composer console, synchro domaine 2026-10-28 08:48:54 -04:00
16 changed files with 1446 additions and 98 deletions
+17 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section > **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.81.1**. > [Unreleased](#unreleased). La dernière version livrée est **2.81.3**.
--- ---
@@ -14,6 +14,22 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.81.3] — 2026-10-28
### Modifié
- **Barre d'apps** — vendange de `dracodev-commons` v0.1.6 : dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
---
## [2.81.2] — 2026-10-28
### Modifié
- **Panneau Spark / console (PR #35)** — style console du panneau, statut « Connecté » intégré au sous-titre de l'en-tête (barre séparée supprimée), texte sous la zone de saisie supprimé, composer identique à la console (pilule, pièces jointes 4 max, textarea auto-extensible, micro dictée, envoi rond bleu), resynchronisation du domaine à chaque ouverture / changement de stockage / retour console, URL console avec `?domain=` ; la console honore `?domain=` valide (persisté avant chargement des discussions, PR agent-commun #7).
---
## [2.81.1] — 2026-10-28 ## [2.81.1] — 2026-10-28
--- ---
+1 -1
View File
@@ -1 +1 @@
2.81.1 2.81.3
+12 -2
View File
@@ -189,15 +189,25 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# #223 : l'origine du service d'agents n'est pas figée — elle # #223 : l'origine du service d'agents n'est pas figée — elle
# est dérivée de la configuration (DRACO_AGENT_URL, sinon # est dérivée de la configuration (DRACO_AGENT_URL, sinon
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel). # l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
from backend.draco_config import agent_origine from backend.draco_config import agent_origine, origine, urls_publiques
agent = agent_origine() agent = agent_origine()
# Fusion #69 : le panneau Alertes unifiées lit les API
# notifications des applications soeurs depuis cet hôte ;
# leurs origines rejoignent connect-src (même résolution
# draco_config que les liens de la barre : explicite >
# dérivation depuis l'hôte de l'agent > défaut officiel).
_soeurs69 = " ".join(sorted({
origine(v)
for k, v in urls_publiques().items()
if k in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""}))
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = (
"default-src 'self'; " "default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; " f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; " "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; " "img-src 'self' data: blob:; "
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent}; " f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent} {_soeurs69}; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; " "font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; " "worker-src 'self' blob:; "
f"frame-src 'self' blob: {agent}; " f"frame-src 'self' blob: {agent}; "
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.81.1" version = "2.81.3"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.81.1" version = "2.81.3"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.81.1", "version": "2.81.3",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.81.1 | **Dernière mise à jour :** 2026-10-28 > **Version :** 2.81.3 | **Dernière mise à jour :** 2026-10-28
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
+39
View File
@@ -0,0 +1,39 @@
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
.draco-alertes-overlay {
position: fixed; inset: 0; z-index: 30000;
display: none; align-items: flex-start; justify-content: center;
padding: 8vh 16px 16px;
background: rgb(0 0 0 / .55); overflow-y: auto;
}
.draco-alertes-box {
width: 720px; max-width: 100%;
background: var(--bg-primary, #1f1f1f);
color: var(--text, #eceee9);
border: 1px solid var(--border, #333);
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
padding: 16px 18px 18px;
}
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
.draco-alertes-close {
border: 1px solid var(--border, #333); background: transparent;
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
}
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
.draco-alertes-item {
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
padding: 9px 12px; margin: 6px 0;
}
.draco-alertes-item.is-active { border-color: #e8762c; }
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
.draco-alertes-title:hover { text-decoration: underline; }
.draco-alertes-detail {
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
.draco-alertes-empty a { color: inherit; }
+427
View File
@@ -0,0 +1,427 @@
/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
*
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
* ouverture du panneau (et au chargement, pour la pastille), le module
* interroge les API de notifications réellement exposées par chaque
* application, avec les identifiants du navigateur (credentials:
* "include"). Aucune alerte n'est inventée : une source injoignable
* ou non authentifiée affiche son état réel (« Connexion requise »)
* avec un lien profond vers sa page native, jamais du vide déguisé.
*
* Sources réelles (audit #69, 2026-10-28) :
* - Flowdeck : GET {base}/api/notifications?limit=50
* -> { notifications: [{id, ntype, title, message, url, is_read,
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
* - TikTak : GET {base}/api/notifications
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
* base + "/notifications".
* - Kanbante : GET {base}/api/events?limit=100
* -> { events: [{repo_full_name, issue_number, event_type,
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
* n'existe dans Kanbante, donc tout y est historique et rien n'y
* est compté comme actif ; lien = base + "/board/" + repo.
* - ObsiGate : AUCUNE notification entrante consultable. Ses
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
* masqués), pas un journal lisible par l'utilisateur. La section
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
*
* Résolution des bases (même ordre que barre-apps v0.1.6) :
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
* 3. href rendu de l'onglet .draco-apptab[data-app] ;
* 4. défaut officiel (production).
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
* lit aussi window.DRACO_URLS (injecté par son backend).
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
* que sur un hôte -dev, exactement comme la barre.
*
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
* pour la pastille (repli : mise à jour directe du badge).
* Insertion des données : textContent uniquement, jamais innerHTML
* avec une valeur venue du serveur.
*/
(function (global) {
"use strict";
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/"
};
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
};
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
var EVENT_LABELS = {
created: "créée", moved: "déplacée", closed: "fermée",
reopened: "rouverte", commented: "commentée"
};
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function joinUrl(base, path) {
var b = withSlash(base);
if (!path) return b;
if (/^https?:\/\//.test(path)) return path;
return b + String(path).replace(/^\//, "");
}
function parseDate(s) {
if (!s) return null;
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
? String(s).replace(" ", "T") + "Z" : s);
return isNaN(t.getTime()) ? null : t;
}
function fmtDate(s) {
var t = parseDate(s);
if (!t) return s ? String(s).slice(0, 16) : "";
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
}
/* Résolution des bases. `env` permet les tests sans navigateur :
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
function resolveUrls(env) {
env = env || {};
var hostname = env.hostname;
if (hostname === undefined) {
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
}
var dev = isDevHost(hostname);
var urls = {};
APP_ORDER.forEach(function (app) {
var u = cleanUrl(env.barUrls && env.barUrls[app])
|| cleanUrl(env.configUrls && env.configUrls[app]);
if (!u && dev) u = DEV_URLS[app] || null;
if (!u) {
var href = null;
if (env.tabHrefs && env.tabHrefs[app]) href = env.tabHrefs[app];
else if (typeof document !== "undefined" && document.querySelector) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
href = tab ? tab.getAttribute("href") : null;
}
u = cleanUrl(href);
}
if (!u && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(env.dracoUrls[app]);
if (!u) {
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
try {
if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
} catch (e) { /* rien */ }
}
if (!u) u = DEFAULT_URLS[app];
urls[app] = withSlash(u);
});
return urls;
}
function browserEnv() {
var cfg = global.DRACO_BAR_CONFIG || {};
var bar = global.DRACO_BAR || {};
var tabs = {};
try {
if (typeof document !== "undefined" && document.querySelectorAll) {
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
for (var i = 0; i < nodes.length; i++) {
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
}
}
} catch (e) { /* rien */ }
return {
barUrls: bar.urls || null, configUrls: cfg.urls || null,
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null
};
}
function normalizeFlowdeck(base, payload) {
var list = (payload && payload.notifications) || [];
return list.map(function (n) {
return {
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
detail: n.message || "", date: n.created_at || "",
active: !n.is_read, state: n.is_read ? "lue" : "active",
kind: n.ntype || "", url: joinUrl(base, n.url || "")
};
});
}
function normalizeTiktak(base, payload) {
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
return list.map(function (n) {
return {
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
detail: n.body || "", date: n.created_at || "",
active: !n.is_read, state: n.is_read ? "lue" : "active",
kind: n.kind || "",
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
};
});
}
function normalizeKanbante(base, payload) {
var list = (payload && payload.events) || [];
return list.map(function (e) {
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
return {
app: "kanbante", id: e.id,
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
detail: detail, date: e.created_at || "",
active: false, state: label, kind: e.event_type || "",
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
};
});
}
/* Actives d'abord, puis date décroissante (sans date en dernier). */
function sortAlerts(list) {
return list.slice().sort(function (a, b) {
if (a.active !== b.active) return a.active ? -1 : 1;
var da = parseDate(a.date), db = parseDate(b.date);
if (da && db) return db - da;
if (da) return -1;
if (db) return 1;
return 0;
});
}
function countActive(list) {
var n = 0;
list.forEach(function (a) { if (a.active) n++; });
return n;
}
/* ---- Rendu (navigateur seulement) ---- */
var panel = null, listEl = null, badgeTimer = null;
function el(tag, cls, text) {
var d = document.createElement(tag);
if (cls) d.className = cls;
if (text !== undefined && text !== null) d.textContent = text;
return d;
}
function ensurePanel() {
if (panel) return panel;
var ov = el("div", "draco-alertes-overlay");
ov.setAttribute("role", "dialog");
ov.setAttribute("aria-label", "Alertes unifiées");
var box = el("div", "draco-alertes-box");
var head = el("div", "draco-alertes-head");
head.appendChild(el("h2", "", "Alertes unifiées"));
var note = el("p", "draco-alertes-note",
"Agrégation à la demande, pas une fédération temps réel : " +
"les alertes sont relues dans chaque application à l'ouverture.");
var close = el("button", "draco-alertes-close", "✕");
close.setAttribute("aria-label", "Fermer");
close.addEventListener("click", closePanel);
head.appendChild(close);
box.appendChild(head);
box.appendChild(note);
listEl = el("div", "draco-alertes-list");
box.appendChild(listEl);
ov.appendChild(box);
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closePanel();
});
document.body.appendChild(ov);
panel = ov;
return ov;
}
function closePanel() { if (panel) panel.style.display = "none"; }
function renderSection(title, items, fallback) {
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
if (!items.length) {
sec.appendChild(el("p", "draco-alertes-empty", fallback));
return sec;
}
items.forEach(function (a) {
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
var main = el("div", "draco-alertes-main");
var link = el("a", "draco-alertes-title", a.title);
link.href = a.url;
main.appendChild(link);
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
var meta = el("div", "draco-alertes-meta",
APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
" · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
main.appendChild(meta);
row.appendChild(main);
sec.appendChild(row);
});
return sec;
}
function render(results, urls) {
ensurePanel();
listEl.textContent = "";
var all = [];
APP_ORDER.forEach(function (app) {
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
});
var actives = sortAlerts(all.filter(function (a) { return a.active; }));
var history = sortAlerts(all.filter(function (a) { return !a.active; }));
listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
APP_ORDER.forEach(function (app) {
var items = actives.filter(function (a) { return a.app === app; });
if (results[app] && results[app].error) {
var sec = renderSection(APP_LABELS[app], [], results[app].error);
listEl.appendChild(sec);
} else if (app === "obsigate") {
listEl.appendChild(renderSection(APP_LABELS[app], [],
"ObsiGate n'expose aucune notification entrante consultable " +
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
} else if (items.length) {
listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
}
});
if (!actives.length) {
listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
}
listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
APP_ORDER.forEach(function (app) {
if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
if (app === "obsigate") {
var link = el("div", "draco-alertes-empty");
var a = el("a", "", "Ouvrir ObsiGate");
a.href = urls.obsigate;
link.appendChild(a);
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", "ObsiGate (0)"));
sec.appendChild(link);
listEl.appendChild(sec);
return;
}
var items = history.filter(function (x) { return x.app === app; });
listEl.appendChild(renderSection(APP_LABELS[app], items,
"Aucun historique disponible pour cette application."));
});
}
function fetchJson(url, timeoutMs) {
var ctrl = null, timer = null;
try {
if (typeof AbortController !== "undefined") {
ctrl = new AbortController();
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
}
} catch (e) { ctrl = null; }
var opts = { credentials: "include" };
if (ctrl) opts.signal = ctrl.signal;
return fetch(url, opts).then(function (r) {
if (timer) clearTimeout(timer);
if (!r.ok) {
var err = new Error("HTTP " + r.status);
err.status = r.status;
throw err;
}
return r.json();
}).catch(function (e) {
if (timer) clearTimeout(timer);
throw e;
});
}
function loadAll() {
var urls = resolveUrls(browserEnv());
var jobs = {
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
tiktak: fetchJson(urls.tiktak + "api/notifications")
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
};
var keys = Object.keys(jobs);
return Promise.all(keys.map(function (k) {
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
var msg = (e && e.status === 401)
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
return [k, { items: [], error: msg }];
});
})).then(function (pairs) {
var results = { obsigate: { items: [] } };
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
return { urls: urls, results: results };
});
}
function setBadge(n) {
try {
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
global.DRACO_BAR.setCounts({ alertes: n });
return;
}
} catch (e) { /* repli ci-dessous */ }
try {
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
b.textContent = n;
b.hidden = !(n > 0);
});
} catch (e) { /* rien */ }
}
function refreshBadge() {
return loadAll().then(function (r) {
var all = [];
APP_ORDER.forEach(function (app) {
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
});
setBadge(countActive(all));
return countActive(all);
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
}
function openPanel() {
ensurePanel();
panel.style.display = "flex";
listEl.textContent = "";
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
loadAll().then(function (r) {
render(r.results, r.urls);
var all = [];
APP_ORDER.forEach(function (app) {
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
});
setBadge(countActive(all));
}).catch(function () {
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
});
}
var api = {
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
_test: {
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
sortAlerts: sortAlerts, countActive: countActive,
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
}
};
global.DRACO_ALERTES = api;
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
/* Pastille au chargement (après les scripts defer de la barre). */
if (typeof document !== "undefined" && document.addEventListener) {
document.addEventListener("DOMContentLoaded", function () {
if (badgeTimer) clearTimeout(badgeTimer);
badgeTimer = setTimeout(refreshBadge, 300);
});
}
})(typeof window !== "undefined" ? window : globalThis);
+30 -3
View File
@@ -2,7 +2,9 @@
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ; v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications — v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ; c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée). v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
v0.1.6 : menus « ••• » et compte (construits par le JS, mêmes jetons
que le menu des applications) ; l'avatar est un vrai <button>.
Jetons repris du prototype validé Console v7.2. Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html> Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */ ou prefers-color-scheme si l'app intégratrice ne force rien. */
@@ -42,6 +44,29 @@
box-shadow: 0 16px 44px rgb(0 0 0 / .28); box-shadow: 0 16px 44px rgb(0 0 0 / .28);
} }
.draco-apps-menu[hidden] { display: none; } .draco-apps-menu[hidden] { display: none; }
/* Menus « ••• » et compte (v0.1.6) — même boîte que le menu des apps,
* ancrés à droite ; leurs items réutilisent .draco-apps-item. */
.draco-more-menu, .draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-more-menu[hidden], .draco-compte-menu[hidden] { display: none; }
.draco-menu-item {
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
padding: 8px 10px; border: 0; border-radius: 10px;
background: transparent; color: var(--ink); font: inherit; text-align: left;
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
border-bottom: 1px solid var(--line); margin-bottom: 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.draco-apps-item { .draco-apps-item {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px; padding: 8px 10px; border-radius: 10px;
@@ -122,8 +147,10 @@
} }
.draco-icon-btn:hover { background: var(--surface-2); } .draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar { .draco-avatar {
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
background: #2f6fed; color: #fff; font-weight: 700; display: grid; place-items: center; flex: none;
background: #2f6fed; color: #fff; font: inherit; font-weight: 700;
cursor: pointer;
} }
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */ /* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
+403 -40
View File
@@ -1,5 +1,10 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ; /* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
* v0.1.3 : menu des applications ouvert par la marque ✦). * v0.1.3 : menu des applications ouvert par la marque ✦ ;
* v0.1.5 : noms publics par configuration — aucun nom public d'un
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
* la publication est séparée) : dérivation -dev par l'hôte courant,
* menu « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
* *
* Aucun gestionnaire inline : un seul écouteur délégué sur le document * Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont * (patron validé par le prototype — les onclick du HTML généré sont
@@ -10,19 +15,158 @@
* current: "tiktak", // app courante * current: "tiktak", // app courante
* domain: "pro", // domaine actif * domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 }, * counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* user: "Bruno", // nom affiché (v0.1.6) ;
* // ou { display_name: "Bruno", username: "bruno" }
* urls: { // bases des applications
* obsigate: "https://og.dracodev.net/",
* flowdeck: "https://flowdeck.dracodev.net/",
* tiktak: "https://tiktak.dracodev.net/",
* kanbante: "https://kanbante.dracodev.net/",
* agent: "https://agent.dracodev.net/",
* spark: "https://spark.dracodev.net/"
* },
* onAction: function (act, el) { … } // l'app relaie les actions * onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas * } // qu'elle ne connaît pas
*
* URL des applications (contrat dev/prod, v0.1.5 puis v0.1.6) —
* résolution, dans l'ordre STRICT, pour chaque app :
* 1. DRACO_BAR_CONFIG.urls.<app>, injectée par l'app hôte (rendu
* serveur ou client) depuis SA configuration d'environnement —
* explicite, donc prioritaire, même sur un hôte -dev ;
* 2. (v0.1.6) SI l'hôte courant est un hôte -dev dracodev.net
* (première étiquette se terminant par -dev : tiktak-dev,
* kanbante-dev, og-dev, flowdeck-dev, agent-dev…), l'URL sœur
* dérivée avec -dev (voir DEV_URLS). C'est le correctif du bug
* constaté sur tiktak-dev / kanbante-dev, où les liens suivaient
* les noms SANS -dev faute de configuration explicite ;
* 3. le href déjà rendu dans le fragment HTML, c.-à-d. le jeton
* {{URL_<APP>}} remplacé côté serveur par l'app hôte ;
* 4. le défaut officiel ci-dessous (production, noms SANS suffixe).
* Un jeton {{URL_…}} non remplacé n'est jamais une URL : il est ignoré
* partout (config injectée comme href rendu) et ne peut pas être exposé.
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
* Spark (panneau-agent), qui suit le même contrat d'injection.
* Actions émises (data-act) : rail, palette, domaine (data-dom), * Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de * alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…). * (ouvrir la palette, le panneau Spark, changer de domaine…).
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu * Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
* des applications, construit depuis les onglets ; c'est le moyen de * ouvre le menu des applications, construit depuis les onglets ;
* changer d'app en mobile, où les onglets sont repliés (v0.1.2). * c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
*
* Menu « ••• » (v0.1.6) — audit des hôtes : ObsiGate est le seul hôte
* connu à gérer l'action "menu" via onAction (il déclenche son propre
* bouton d'en-tête #header-menu-btn) ; les autres hôtes ne la gèrent
* pas et le bouton restait mort (relais vers un onAction absent, puis
* événement draco:bar sans écouteur). Comportement désormais :
* - sans onAction : un menu par défaut FONCTIONNEL s'ouvre — accès à
* la console agent (URL agent résolue, donc dérivée -dev sur un
* hôte -dev) et aux quatre applications (URL résolues) ; fermeture
* par clic extérieur, Échap ou choix d'un lien ;
* - avec onAction : l'hôte garde la main (comportement historique,
* zéro régression pour ObsiGate) ; il peut demander explicitement
* le menu par défaut en retournant false pour l'action "menu".
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
* un « B » codé en dur dans le fragment, est un vrai <button>
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
* la première lettre du title {{UTILISATEUR}} remplacé ; sans nom connu,
* le neutre ✦ est conservé (jamais de lettre codée en dur). Le clic
* ouvre un petit menu affichant le nom de l'utilisateur ; les items
* Profil / Déconnexion sont relayés à l'hôte via onAction ou, à défaut,
* via l'événement draco:bar (détail { act: "profil" | "deconnexion" }),
* mais le menu affiche l'identité même sans gestionnaire hôte.
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
*/ */
(function () { (function () {
"use strict"; "use strict";
var cfg = window.DRACO_BAR_CONFIG || {}; var cfg = window.DRACO_BAR_CONFIG || {};
/* Défauts OFFICIELS (production) — les seuls noms publics présents
* dans ce fichier, documentés comme défauts (contrat dev/prod). */
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
agent: "https://agent.dracodev.net/",
spark: "https://spark.dracodev.net/"
};
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
* est lui-même un hôte -dev dracodev.net (voir isDevHost). spark est
* volontairement absent : aucun hôte spark-dev connu, aucune URL
* inventée ; spark retombe sur href rendu puis défaut prod. */
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
};
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
* se terminant par -dev ET domaine dracodev.net (un suffixe -dev sur
* un autre domaine, ou un hôte piège du type x-dev.dracodev.net.evil,
* ne déclenche rien). Insensible à la casse. */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* Un jeton {{…}} non remplacé n'est pas une URL : l'ignorer. */
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function resolveUrls() {
var host = "";
try { host = (window.location && window.location.hostname) || ""; } catch (e) { host = ""; }
var dev = isDevHost(host);
var urls = {};
Object.keys(DEFAULT_URLS).forEach(function (app) {
/* 1. Config explicite de l'hôte — prioritaire, même sur -dev. */
var u = cleanUrl(cfg.urls && cfg.urls[app]);
/* 2. Dérivation -dev par l'hôte courant (spark : pas de dérivée). */
if (!u && dev) u = DEV_URLS[app] || null;
/* 3. href rendu dans le fragment (jeton remplacé côté serveur). */
if (!u) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var href = tab ? tab.getAttribute("href") : null;
u = cleanUrl(href);
}
/* 4. Défaut officiel (production). */
if (!u) u = DEFAULT_URLS[app];
urls[app] = withSlash(u);
});
return urls;
}
function applyUrls(urls) {
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app && urls[t.dataset.app]) t.setAttribute("href", urls[t.dataset.app]);
});
}
var resolvedUrls = resolveUrls();
function setCounts(counts) { function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) { Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */ /* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -43,26 +187,122 @@
}); });
} }
/* ── Menu des applications (v0.1.3) ────────────────────────────── /* ── Identité / avatar (v0.1.6) ───────────────────────────────────
* Construit depuis les onglets présents dans la barre : mêmes * Nom : cfg.user (chaîne, ou objet display_name / name / username),
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦ * sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le * remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
* choix d'une application (la navigation suit son cours). */ * ✦ du fragment est conservé — jamais de lettre codée en dur. */
function userName() {
var u = cfg.user;
if (typeof u === "string" && u.replace(/\s/g, "")) return u.trim();
if (u && typeof u === "object") {
var n = u.display_name || u.name || u.username;
if (n && String(n).replace(/\s/g, "")) return String(n).trim();
}
return null;
}
function titleName() {
var av = document.querySelector(".draco-avatar");
var t = av ? av.getAttribute("title") : null;
if (t && t.indexOf("{{") === -1 && t.replace(/\s/g, "")) return t.trim();
return null;
}
function displayName() { return userName() || titleName(); }
function syncAvatar() {
var av = document.querySelector(".draco-avatar");
if (!av) return;
/* Compatibilité avec un fragment vendangé antérieur (avatar <span>
* sans data-act) : le rendre déclencheur du menu compte. */
if (!av.getAttribute("data-act")) av.setAttribute("data-act", "compte");
if (!av.getAttribute("aria-haspopup")) av.setAttribute("aria-haspopup", "menu");
var name = displayName();
if (name) {
av.textContent = name.charAt(0).toUpperCase();
av.setAttribute("title", name);
av.setAttribute("aria-label", "Compte : " + name);
}
}
/* ── Menus déroulants (apps, •••, compte) ──────────────────────────
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
* déclencheur. Les href des menus sont (re)synchronisés avec
* resolvedUrls à chaque ouverture, pour que setUrls et la dérivation
* -dev s'y reflètent même si le menu a été construit avant. */
var bar = document.querySelector(".draco-bar"); var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null; var brand = bar ? bar.querySelector(".draco-brand") : null;
var menu = null; var moreBtn = bar ? bar.querySelector('[data-act="menu"]') : null;
var avatarBtn = document.querySelector(".draco-avatar");
var appsMenu = null, moreMenu = null, compteMenu = null;
function buildMenu() { function setExpanded(btn, open) {
if (!bar || !brand || menu) return; if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
menu = document.createElement("div"); }
menu.className = "draco-apps-menu"; function refreshMenuHrefs(menu) {
menu.hidden = true; if (!menu) return;
menu.setAttribute("role", "menu"); menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
menu.setAttribute("aria-label", "Applications"); if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
setExpanded(btn, open);
}
function closeAllPopups() {
if (appsMenu) togglePopup(appsMenu, brand, false);
if (moreMenu) togglePopup(moreMenu, moreBtn, false);
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
}
function tabLabel(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
return fort ? fort.textContent : (APP_LABELS[app] || app);
}
function tabRole(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
return petit ? petit.textContent : "";
}
function menuLink(app, nom, role) {
var a = document.createElement("a");
a.className = "draco-apps-item draco-menu-item";
a.href = resolvedUrls[app] || "#";
a.dataset.app = app;
a.setAttribute("role", "menuitem");
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var n = document.createElement("span");
n.className = "draco-apps-item-nom";
n.textContent = nom;
txt.appendChild(n);
if (role) {
var r = document.createElement("span");
r.className = "draco-apps-item-role";
r.textContent = role;
txt.appendChild(r);
}
a.appendChild(txt);
return a;
}
/* Menu des applications (v0.1.3) — construit depuis les onglets
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
* extérieur, Échap, ou le choix d'une application (la navigation
* suit son cours). */
function buildAppsMenu() {
if (!bar || !brand || appsMenu) return;
appsMenu = document.createElement("div");
appsMenu.className = "draco-apps-menu";
appsMenu.hidden = true;
appsMenu.setAttribute("role", "menu");
appsMenu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) { document.querySelectorAll(".draco-apptab").forEach(function (t) {
var a = document.createElement("a"); var a = document.createElement("a");
a.className = "draco-apps-item"; a.className = "draco-apps-item";
a.href = t.getAttribute("href"); a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
a.dataset.app = t.dataset.app; a.dataset.app = t.dataset.app;
a.setAttribute("role", "menuitem"); a.setAttribute("role", "menuitem");
var ico = t.querySelector(".draco-apptab-ico"); var ico = t.querySelector(".draco-apptab-ico");
@@ -84,44 +324,167 @@
a.appendChild(txt); a.appendChild(txt);
var badge = t.querySelector(".draco-badge"); var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true)); if (badge) a.appendChild(badge.cloneNode(true));
menu.appendChild(a); appsMenu.appendChild(a);
}); });
bar.appendChild(menu); bar.appendChild(appsMenu);
}
function toggleAppsMenu(force) {
buildAppsMenu();
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
} }
function toggleMenu(force) { /* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
buildMenu(); * Construit depuis les URL résolues (donc dérivées -dev sur un hôte
if (!menu) return; * -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
var open = typeof force === "boolean" ? force : menu.hidden; * l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
menu.hidden = !open; function buildMoreMenu() {
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false"); if (!bar || !moreBtn || moreMenu) return;
moreMenu = document.createElement("div");
moreMenu.className = "draco-more-menu";
moreMenu.hidden = true;
moreMenu.setAttribute("role", "menu");
moreMenu.setAttribute("aria-label", "Plus d'actions");
moreMenu.appendChild(menuLink("agent", "Console agent", "Agent commun"));
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
moreMenu.appendChild(sep);
["obsigate", "flowdeck", "tiktak", "kanbante"].forEach(function (app) {
moreMenu.appendChild(menuLink(app, tabLabel(app), tabRole(app)));
});
bar.appendChild(moreMenu);
}
function toggleMoreMenu(force) {
buildMoreMenu();
if (!moreMenu) return;
if (force !== false) refreshMenuHrefs(moreMenu);
togglePopup(moreMenu, moreBtn, force);
}
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
* remplit l'avatar depuis /api/auth/me après chargement). */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
compteMenu.className = "draco-compte-menu";
compteMenu.hidden = true;
compteMenu.setAttribute("role", "menu");
compteMenu.setAttribute("aria-label", "Compte");
var head = document.createElement("div");
head.className = "draco-compte-nom";
compteMenu.appendChild(head);
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", pair[0]);
b.setAttribute("role", "menuitem");
b.textContent = pair[1];
compteMenu.appendChild(b);
});
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
if (!compteMenu) return;
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
}
function toggleCompteMenu(force) {
buildCompteMenu();
if (!compteMenu) return;
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
togglePopup(compteMenu, avatarBtn, force);
}
/* Relais d'une action vers l'hôte : onAction si présent, sinon
* événement draco:bar (contrat historique des actions relayées). */
function relay(act, el, ev) {
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
} }
document.addEventListener("click", function (ev) { document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]"); /* Items du menu compte : relais + fermeture, jamais de navigation. */
if (el && el.dataset.act === "apps") { var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
ev.preventDefault(); if (compteItem) {
toggleMenu(); relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
toggleCompteMenu(false);
return; return;
} }
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) { var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
toggleMenu(false); if (el && el.dataset.act === "apps") {
ev.preventDefault();
if (moreMenu) toggleMoreMenu(false);
if (compteMenu) toggleCompteMenu(false);
toggleAppsMenu();
return;
}
if (el && el.dataset.act === "menu") {
if (appsMenu) toggleAppsMenu(false);
if (compteMenu) toggleCompteMenu(false);
/* Hôte prioritaire : il garde la main, sauf s'il demande
* explicitement le menu par défaut en retournant false.
* Sans onAction, le menu par défaut s'ouvre (il ne restait
* auparavant qu'un événement draco:bar sans écouteur). */
if (typeof cfg.onAction === "function") {
var handled = cfg.onAction("menu", el, ev);
if (handled === false) toggleMoreMenu();
} else {
toggleMoreMenu();
}
return;
}
if (el && el.dataset.act === "compte") {
if (appsMenu) toggleAppsMenu(false);
if (moreMenu) toggleMoreMenu(false);
toggleCompteMenu();
return;
}
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
* sur son déclencheur déjà traité ci-dessus). */
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
ev.target.closest(".draco-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups();
} }
if (!el) return; if (!el) return;
var act = el.dataset.act; var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom); if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev); relay(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
}); });
document.addEventListener("keydown", function (ev) { document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") toggleMenu(false); if (ev.key === "Escape") closeAllPopups();
}); });
/* API publique : l'app met les compteurs à jour sans recharger. */ /* API publique : l'app met les compteurs à jour sans recharger.
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain }; * setUrls(urls) fusionne de nouvelles bases (même contrat que
* DRACO_BAR_CONFIG.urls — donc prioritaires sur la dérivation -dev)
* et les ré-applique aux onglets et aux menus ; DRACO_BAR.urls expose
* les bases résolues (défauts inclus), pour le panneau Spark et tout
* autre consommateur de l'hôte. */
window.DRACO_BAR = {
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
urls: resolvedUrls,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
}
};
buildMenu(); applyUrls(resolvedUrls);
buildAppsMenu();
buildMoreMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
if (cfg.current) setCurrent(cfg.current); if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain); if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts); if (cfg.counts) setCounts(cfg.counts);
+89 -17
View File
@@ -37,16 +37,19 @@
display: flex; align-items: center; gap: 9px; display: flex; align-items: center; gap: 9px;
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
} }
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; } .draco-panneau-titres { min-width: 0; flex: 1; }
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); } .draco-panneau-hd .titre { font-weight: 700; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.draco-panneau-hd .soustitre {
font-size: 12px; color: var(--p-doux); display: flex; align-items: center; gap: 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-panneau-hd .soustitre .point { width: 8px; height: 8px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-hd .soustitre.ok .point { background: #2ea36b; }
.draco-panneau-hd .soustitre.err .point { background: #cf4b32; }
.draco-panneau-hd .fermer { .draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte); margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px; border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px; flex: none;
} }
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; }
/* Barre des discussions (façon Muse) */ /* Barre des discussions (façon Muse) */
.draco-disc-bar { .draco-disc-bar {
display: flex; align-items: center; gap: 7px; display: flex; align-items: center; gap: 7px;
@@ -112,18 +115,87 @@
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; } .draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; } .draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; } .draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); } .draco-panneau-ft { padding: 10px 12px 11px; background: var(--p-fond); }
.draco-panneau-form { display: flex; gap: 8px; } /* Puces de suggestions, comme la console : au-dessus du composer. */
.draco-panneau-form input { .draco-puces {
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px; display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; padding: 0 2px 8px;
font: inherit; background: var(--p-champ); color: var(--p-texte);
} }
.draco-panneau-form button { .draco-puces button {
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff; border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer; border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
}
.draco-puces button:hover { background: var(--p-chip-survol); }
/* ── Composer façon console (pilule) ─────────────────────────
* +, textarea auto-extensible, micro ou envoi rond bleu ↑ ;
* pendant la dictée, la rangée d'enregistrement remplace la saisie. */
.draco-composer {
display: flex; flex-direction: column; align-items: stretch; gap: 6px;
background: var(--p-surface); border: 1px solid var(--p-bord);
border-radius: 24px; padding: 8px 10px;
box-shadow: 0 1px 4px rgb(0 0 0 / .05);
}
.draco-composer [hidden] { display: none !important; }
.draco-composer-ligne { display: flex; align-items: flex-end; gap: 6px; }
.draco-composer textarea {
flex: 1; border: 0; background: none; resize: none; outline: none;
padding: 8px 2px; max-height: 152px; overflow-y: auto; line-height: 1.45;
min-height: calc(1.45em + 16px); font: inherit; color: var(--p-texte);
scrollbar-width: none;
}
.draco-composer textarea::-webkit-scrollbar { width: 0; height: 0; display: none; }
.draco-composer .composer-btn {
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
background: var(--p-chip); color: var(--p-texte); font-size: 17px;
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
}
.draco-composer .composer-btn:hover { background: var(--p-chip-survol); }
.draco-composer .composer-btn.danger { color: var(--p-danger); }
.draco-composer .composer-envoi {
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
background: #2f6fed; color: #fff; font-size: 17px; font-weight: 700;
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
}
.draco-composer .composer-envoi:disabled { opacity: .5; }
.draco-composer .composer-btn .ic, .draco-composer .composer-envoi .ic { width: 19px; height: 19px; }
.draco-composer-pieces { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px 2px 0; }
.draco-composer-pieces .piece-carte {
position: relative; display: flex; align-items: center; gap: 8px;
background: var(--p-chip); border: 1px solid var(--p-chip-bord);
border-radius: 12px; padding: 5px 9px 5px 5px; max-width: 230px;
}
.draco-composer-pieces .piece-carte img { width: 42px; height: 42px; object-fit: cover; border-radius: 8px; display: block; }
.draco-composer-pieces .piece-nom { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.draco-composer-pieces .piece-doc-ext {
width: 42px; height: 42px; display: flex; align-items: center; justify-content: center;
background: var(--p-chip); border: 1px solid var(--p-chip-bord); border-radius: 8px;
}
.draco-composer-pieces .piece-ext { font-weight: 700; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; opacity: .75; }
.draco-composer-pieces .piece-retirer {
position: absolute; top: -7px; right: -7px; width: 20px; height: 20px;
border-radius: 50%; border: 0; background: var(--p-texte); color: var(--p-fond);
font-size: 10px; line-height: 1; padding: 0; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.draco-composer-enreg { display: flex; align-items: center; gap: 10px; padding: 0 2px; }
.draco-composer-enreg .enreg-temps { font-variant-numeric: tabular-nums; font-weight: 700; min-width: 36px; }
.draco-composer-enreg .enreg-texte {
flex: 1; min-width: 0; color: var(--p-doux); font-size: 13px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-composer-enreg .enreg-onde { display: flex; align-items: center; gap: 3px; height: 26px; flex: none; }
.draco-composer-enreg .enreg-onde span { width: 4px; height: 30%; border-radius: 2px; background: #2f6fed; }
.draco-composer.en-cours .enreg-onde span { animation: onde 1.05s ease-in-out infinite; }
.draco-composer-enreg .enreg-onde span:nth-child(3n+2) { animation-delay: .14s; }
.draco-composer-enreg .enreg-onde span:nth-child(3n) { animation-delay: .28s; }
@keyframes onde { 0%, 100% { height: 22%; } 50% { height: 96%; } }
/* Pièces jointes dans une bulle utilisateur */
.draco-msg-pieces { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }
.draco-msg-pieces img { max-width: 150px; max-height: 120px; border-radius: 10px; display: block; }
.draco-msg-pieces .piece-fichier {
display: inline-flex; align-items: center; gap: 7px;
background: var(--p-chip); border: 1px solid var(--p-chip-bord); color: var(--p-texte);
border-radius: 9px; padding: 6px 9px; font-size: 12px;
} }
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; } .draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; } .draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; } .draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
+332 -23
View File
@@ -40,7 +40,10 @@
var convKind = null; // 'principale' | 'parallele' var convKind = null; // 'principale' | 'parallele'
var convTitre = ""; var convTitre = "";
var etatConnu = false; var etatConnu = false;
var domaineCharge = null; // domaine des discussions affichées
var consoleUrlCharge = null; // URL (disc+domaine+app) de l'iframe console
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null; var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var microEl = null, piecesEl = null, fichierEl = null;
var titreEl = null, tiroirEl = null; var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0; var versionEl = null, versionConnue = "", versionMaj = 0;
@@ -68,11 +71,16 @@
return correspondance[h] || null; return correspondance[h] || null;
} }
function urlConsole() { function urlConsole() {
/* Le domaine de l'hôte est passé explicitement : la console
* vit sur une autre origine, son localStorage ne voit jamais
* celui de l'app hôte — sans ce paramètre, elle rouvrait
* toujours le domaine de sa propre origine, pas celui du site. */
var params = []; var params = [];
var id = convId || convStockee(); var id = convId || convStockee();
if (id) params.push("disc=" + encodeURIComponent(id)); if (id) params.push("disc=" + encodeURIComponent(id));
var app = appHote(); var app = appHote();
if (app) params.push("app=" + encodeURIComponent(app)); if (app) params.push("app=" + encodeURIComponent(app));
params.push("domain=" + encodeURIComponent(domaine()));
return API + "/" + (params.length ? "?" + params.join("&") : ""); return API + "/" + (params.length ? "?" + params.join("&") : "");
} }
@@ -147,7 +155,15 @@
function ouvrirConsole() { function ouvrirConsole() {
assurerSurimpression(); assurerSurimpression();
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole(); /* L'iframe n'est rechargée que si sa cible a changé
* (discussion, domaine ou app) : même cible = état conservé,
* cible différente = la console s'ajuste, au lieu de garder
* la discussion du premier chargement. */
var url = urlConsole();
if (!surCadre.getAttribute("src") || url !== consoleUrlCharge) {
surCadre.src = url;
consoleUrlCharge = url;
}
if (surOuverte) return; if (surOuverte) return;
surOuverte = true; surOuverte = true;
/* Naissance à la géométrie du panneau (ancrée à droite), puis /* Naissance à la géométrie du panneau (ancrée à droite), puis
@@ -175,7 +191,12 @@
setTimeout(terminer, 380); // filet si transitionend ne vient pas setTimeout(terminer, 380); // filet si transitionend ne vient pas
surEl.style.width = largeurPanneau() + "px"; surEl.style.width = largeurPanneau() + "px";
} }
function modeSidebarConsole() { retrecirConsole(); } function modeSidebarConsole() {
retrecirConsole();
/* Retour au panneau : si le domaine a changé pendant que la
* console occupait tout l'écran, le panneau s'ajuste aussi. */
resynchroniserDomaine();
}
function fermerAgentConsole() { fermer(); retrecirConsole(); } function fermerAgentConsole() { fermer(); retrecirConsole(); }
/* ── Thème : suivre l'app hôte ─────────────────────────────── */ /* ── Thème : suivre l'app hôte ─────────────────────────────── */
@@ -246,13 +267,10 @@
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' + '<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
'<div class="draco-panneau-hd">' + '<div class="draco-panneau-hd">' +
' <span style="font-size:19px;color:#7c5cd6">✦</span>' + ' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
' <div><div class="titre">Spark — agent commun</div>' + ' <div class="draco-panneau-titres"><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre">Service d\'agents dracodev</div></div>' + ' <div class="soustitre"><span class="point"></span><span class="txt">Connexion au service…</span></div></div>' +
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' + ' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
'</div>' + '</div>' +
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
'</div>' +
'<div class="draco-disc-bar">' + '<div class="draco-disc-bar">' +
' <span class="d-titre">Discussion principale</span>' + ' <span class="d-titre">Discussion principale</span>' +
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' + ' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
@@ -266,17 +284,40 @@
' <button type="button" data-outil="missions">🚀 Missions</button>' + ' <button type="button" data-outil="missions">🚀 Missions</button>' +
'</div>' + '</div>' +
'<div class="draco-panneau-msgs"></div>' + '<div class="draco-panneau-msgs"></div>' +
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' + '<div class="draco-panneau-ft">' +
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' + ' <div class="draco-puces">' +
' <button type="submit">Envoyer</button></form>' + ' <button type="button" data-sugg="resume">Résumer ma journée</button>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' + ' <button type="button" data-sugg="projets">Où en sont mes projets ?</button>' +
' <button type="button" data-sugg="chercher">Chercher partout…</button>' +
' </div>' +
' <form class="draco-panneau-form draco-composer">' +
' <div class="draco-composer-pieces" hidden></div>' +
' <div class="draco-composer-ligne">' +
' <button type="button" class="composer-btn" data-comp="plus" title="Joindre une image ou un document" aria-label="Joindre une image ou un document"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>' +
' <textarea rows="1" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark"></textarea>' +
' <input type="file" accept="image/*,.pdf,.docx,.odt,.txt,.md,.html,.csv" multiple hidden>' +
' <button type="button" class="composer-btn" data-comp="micro" title="Dictée vocale" aria-label="Dictée vocale" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg></button>' +
' <button type="submit" class="composer-envoi" title="Envoyer" aria-label="Envoyer" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V4.5"/><path d="m5.5 11 6.5-6.5 6.5 6.5"/></svg></button>' +
' </div>' +
' <div class="draco-composer-enreg" hidden>' +
' <button type="button" class="composer-btn danger" data-comp="enreg-annuler" title="Annuler la dictée" aria-label="Annuler la dictée"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>' +
' <span class="enreg-temps">0:00</span>' +
' <div class="enreg-onde" aria-hidden="true"></div>' +
' <span class="enreg-texte"></span>' +
' <button type="button" class="composer-btn" data-comp="enreg-stop" title="Arrêter et corriger la transcription" aria-label="Arrêter la dictée"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/></svg></button>' +
' <button type="button" class="composer-envoi" data-comp="enreg-envoyer" title="Arrêter et envoyer" aria-label="Arrêter et envoyer"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V4.5"/><path d="m5.5 11 6.5-6.5 6.5 6.5"/></svg></button>' +
' </div>' +
' </form>' +
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' + ' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
'</div>'; '</div>';
document.body.appendChild(root); document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs"); msgsEl = root.querySelector(".draco-panneau-msgs");
etatEl = root.querySelector(".draco-panneau-etat"); etatEl = root.querySelector(".draco-panneau-hd .soustitre");
inputEl = root.querySelector("input"); inputEl = root.querySelector(".draco-composer textarea");
sendBtn = root.querySelector("button[type=submit]"); sendBtn = root.querySelector(".draco-composer button[type=submit]");
microEl = root.querySelector('[data-comp="micro"]');
piecesEl = root.querySelector(".draco-composer-pieces");
fichierEl = root.querySelector(".draco-composer input[type=file]");
titreEl = root.querySelector(".d-titre"); titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions"); tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version"); versionEl = root.querySelector(".draco-panneau-version");
@@ -288,6 +329,42 @@
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations); root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions); root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
root.querySelector("form").addEventListener("submit", envoyer); root.querySelector("form").addEventListener("submit", envoyer);
inputEl.addEventListener("input", majComposer);
inputEl.addEventListener("keydown", function (ev) {
if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); envoyerComposer(); }
});
root.querySelector('[data-comp="plus"]').addEventListener("click", function () { fichierEl.click(); });
fichierEl.addEventListener("change", function (ev) {
ajouterFichiers(ev.target.files);
ev.target.value = "";
});
microEl.addEventListener("click", demarrerDictee);
root.querySelector('[data-comp="enreg-stop"]').addEventListener("click", function () { arreterDictee("stop"); });
root.querySelector('[data-comp="enreg-annuler"]').addEventListener("click", function () { arreterDictee("annuler"); });
root.querySelector('[data-comp="enreg-envoyer"]').addEventListener("click", function () { arreterDictee("envoyer"); });
Array.prototype.forEach.call(root.querySelectorAll("[data-sugg]"), function (b) {
b.addEventListener("click", function () {
var mode = b.getAttribute("data-sugg");
if (mode === "resume") {
envoyerMessage("Résume ma journée : mes tâches du jour, les cartes en Révision et l'activité récente.", []);
} else if (mode === "projets") {
envoyerMessage("Où en sont mes projets ? Fais le point sur l'état des projets et du board.", []);
} else {
inputEl.value = "Cherche « » partout : ";
majComposer();
inputEl.focus();
inputEl.setSelectionRange(9, 9);
}
});
});
/* Domaine changé dans un autre onglet pendant que le panneau
* existe : l'événement storage ne part que vers les autres
* documents, donc ici il arrive bien. Le changement dans le
* même onglet est rattrapé à chaque ouverture (ouvrir()). */
window.addEventListener("storage", function (ev) {
if (ev.key === "draco_domain") resynchroniserDomaine();
});
majComposer();
brancherPoignee(root.querySelector(".draco-poignee")); brancherPoignee(root.querySelector(".draco-poignee"));
appliquerTheme(); appliquerTheme();
surveillerTheme(); surveillerTheme();
@@ -326,8 +403,13 @@
} }
function majEtat(cls, texte) { function majEtat(cls, texte) {
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls; /* Statut intégré au titre (sous-titre de l'en-tête) : plus de
etatEl.querySelector(".txt").textContent = texte; * barre d'état séparée. Le point vert/rouge/gris reste lisible
* sans prendre une rangée entière. */
if (!etatEl) return;
etatEl.className = "soustitre " + cls;
var t = etatEl.querySelector(".txt");
if (t) t.textContent = texte;
} }
function majTitre() { function majTitre() {
titreEl.textContent = convTitre || "Discussion principale"; titreEl.textContent = convTitre || "Discussion principale";
@@ -400,9 +482,21 @@
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }); .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
} }
/* Recharge la discussion du domaine courant quand il a changé
* depuis le dernier chargement (ouverture du panneau, autre
* onglet, retour du mode console). Sans changement : rien —
* on ne casse jamais une conversation en cours pour rien. */
function resynchroniserDomaine() {
if (!root || domaine() === domaineCharge) return;
msgsEl.innerHTML = "";
tiroirEl.hidden = true;
demarrerDiscussions();
}
function demarrerDiscussions() { function demarrerDiscussions() {
chargerListeDiscussions() chargerListeDiscussions()
.then(function (d) { .then(function (d) {
domaineCharge = domaine();
var precedente = convStockee(); var precedente = convStockee();
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []); var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0]; var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
@@ -413,6 +507,7 @@
chargerHistorique(); chargerHistorique();
}) })
.catch(function () { .catch(function () {
domaineCharge = null; // échec : la prochaine ouverture réessaiera
ajouterMsg("sys", "Discussions : service injoignable pour le moment."); ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
}); });
} }
@@ -780,19 +875,232 @@
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); }); .catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
} }
function envoyer(ev) { /* ── Composer façon console ────────────────────────────────
ev.preventDefault(); * Mêmes règles que la console : + (images/documents, 4 max),
var texte = inputEl.value.trim(); * textarea auto-extensible, micro affiché seulement sans texte
if (!texte) return; * ni pièce et si le navigateur le supporte, envoi rond bleu
* affiché seulement avec texte ou pièce. */
var piecesEnAttente = [];
var TYPES_IMAGES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
var TYPES_DOCUMENTS = ["application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.oasis.opendocument.text",
"text/plain", "text/markdown", "text/html", "text/csv"];
var TAILLE_MAX_IMAGE = 5 * 1024 * 1024;
var TAILLE_MAX_DOC = 10 * 1024 * 1024;
function majComposer() {
if (!inputEl) return;
var aTexte = !!(inputEl.value && inputEl.value.trim());
sendBtn.hidden = !(aTexte || piecesEnAttente.length);
if (microEl) microEl.hidden = !dicteeSupportee || aTexte || piecesEnAttente.length;
inputEl.style.height = "auto";
var sh = inputEl.scrollHeight;
inputEl.style.height = sh ? Math.min(sh, 152) + "px" : "";
}
function rendrePiecesEnAttente() {
piecesEl.innerHTML = "";
piecesEl.hidden = !piecesEnAttente.length;
piecesEnAttente.forEach(function (p, i) {
var carte = document.createElement("div");
carte.className = "piece-carte";
var apercu;
if (TYPES_IMAGES.indexOf(p.type) >= 0) {
apercu = document.createElement("img");
apercu.src = p.dataUrl; apercu.alt = p.nom;
} else {
apercu = document.createElement("span");
apercu.className = "piece-doc-ext";
var ext = document.createElement("span");
ext.className = "piece-ext";
ext.textContent = (p.nom || "").split(".").pop() || "doc";
apercu.appendChild(ext);
}
var nom = document.createElement("span");
nom.className = "piece-nom"; nom.textContent = p.nom; nom.title = p.nom;
var x = document.createElement("button");
x.type = "button"; x.className = "piece-retirer"; x.textContent = "✕";
x.title = "Retirer " + p.nom;
x.setAttribute("aria-label", "Retirer " + p.nom);
x.addEventListener("click", function () {
piecesEnAttente.splice(i, 1);
rendrePiecesEnAttente(); majComposer();
});
carte.appendChild(apercu); carte.appendChild(nom); carte.appendChild(x);
piecesEl.appendChild(carte);
});
}
function ajouterFichiers(fichiers) {
Array.prototype.forEach.call(fichiers, function (f) {
if (piecesEnAttente.length >= 4) {
ajouterMsg("sys", "Au plus 4 pièces par message.");
return;
}
var estImage = TYPES_IMAGES.indexOf(f.type) >= 0;
var estDoc = TYPES_DOCUMENTS.indexOf(f.type) >= 0;
if (!estImage && !estDoc) {
ajouterMsg("sys", "« " + f.name + " » refusée : images PNG, JPEG, GIF ou WebP, ou documents PDF, DOCX, ODT, TXT, MD, HTML ou CSV seulement.");
return;
}
if (estImage && f.size > TAILLE_MAX_IMAGE) {
ajouterMsg("sys", "« " + f.name + " » refusée : 5 Mo maximum par image.");
return;
}
if (estDoc && f.size > TAILLE_MAX_DOC) {
ajouterMsg("sys", "« " + f.name + " » refusée : 10 Mo maximum par document.");
return;
}
var lecteur = new FileReader();
lecteur.onload = function () {
var dataUrl = String(lecteur.result || "");
piecesEnAttente.push({
nom: f.name, type: f.type,
base64: dataUrl.split(",")[1] || "", dataUrl: dataUrl
});
rendrePiecesEnAttente(); majComposer();
};
lecteur.readAsDataURL(f);
});
}
function envoyerComposer() {
var t = inputEl.value;
var pieces = piecesEnAttente;
piecesEnAttente = [];
inputEl.value = ""; inputEl.value = "";
ajouterMsg("user", texte); rendrePiecesEnAttente();
majComposer();
envoyerMessage(t, pieces);
}
/* ── Dictée vocale — Web Speech API, 100 % navigateur, comme
* la console (fr-CA, résultats intérimaires en direct). */
var RecoVoc = window.SpeechRecognition || window.webkitSpeechRecognition;
var dicteeSupportee = !!RecoVoc;
var reco = null, enregEnCours = false, enregDebut = 0, enregMinuteur = null;
var texteAvantDictee = "", transcriptionFinale = "";
var modeFinDictee = "stop", dicteeErreur = null;
function demarrerDictee() {
if (!dicteeSupportee || enregEnCours) return;
texteAvantDictee = inputEl.value;
transcriptionFinale = "";
modeFinDictee = "stop";
dicteeErreur = null;
try { reco = new RecoVoc(); } catch (e) { reco = null; return; }
reco.lang = "fr-CA";
reco.interimResults = true;
reco.continuous = true;
reco.onresult = function (ev) {
var interim = "";
for (var i = ev.resultIndex; i < ev.results.length; i++) {
var r = ev.results[i];
if (r.isFinal) transcriptionFinale += r[0].transcript;
else interim += r[0].transcript;
}
var affiche = (texteAvantDictee + " " + transcriptionFinale + interim).trim();
root.querySelector(".enreg-texte").textContent = affiche;
inputEl.value = affiche;
};
reco.onerror = function (ev) {
if (ev.error === "aborted") return;
dicteeErreur = ev.error || "erreur";
modeFinDictee = "annuler";
};
reco.onend = terminerDictee;
try { reco.start(); } catch (e) { reco = null; return; }
enregEnCours = true;
enregDebut = Date.now();
root.querySelector(".enreg-temps").textContent = "0:00";
root.querySelector(".enreg-texte").textContent = "";
var onde = root.querySelector(".enreg-onde");
if (!onde.children.length) {
for (var j = 0; j < 14; j++) onde.appendChild(document.createElement("span"));
}
root.querySelector(".draco-composer-ligne").hidden = true;
root.querySelector(".draco-composer-enreg").hidden = false;
root.querySelector(".draco-composer").classList.add("en-cours");
enregMinuteur = window.setInterval(function () {
var s = Math.floor((Date.now() - enregDebut) / 1000);
root.querySelector(".enreg-temps").textContent = Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
}, 250);
}
function arreterDictee(mode) {
modeFinDictee = mode;
if (reco) {
try { reco.stop(); } catch (e) { terminerDictee(); }
} else {
terminerDictee();
}
}
function terminerDictee() {
if (!enregEnCours) return;
enregEnCours = false;
if (enregMinuteur) { window.clearInterval(enregMinuteur); enregMinuteur = null; }
reco = null;
root.querySelector(".draco-composer-ligne").hidden = false;
root.querySelector(".draco-composer-enreg").hidden = true;
root.querySelector(".draco-composer").classList.remove("en-cours");
if (modeFinDictee === "annuler") {
inputEl.value = texteAvantDictee;
if (dicteeErreur) {
ajouterMsg("sys", "Dictée vocale interrompue (" + dicteeErreur + ") — micro refusé ou indisponible ?");
dicteeErreur = null;
}
} else {
inputEl.value = (texteAvantDictee + " " + transcriptionFinale).trim();
}
var mode = modeFinDictee;
modeFinDictee = "stop";
majComposer();
if (mode === "envoyer") envoyerComposer();
else inputEl.focus();
}
function envoyer(ev) {
if (ev) ev.preventDefault();
envoyerComposer();
}
function envoyerMessage(texte, pieces) {
texte = (texte || "").trim();
pieces = pieces || [];
if (!texte && !pieces.length) return;
var el = ajouterMsg("user", texte);
if (pieces.length) {
var zone = document.createElement("div");
zone.className = "draco-msg-pieces";
pieces.forEach(function (p) {
if (TYPES_IMAGES.indexOf(p.type) >= 0) {
var img = document.createElement("img");
img.src = p.dataUrl; img.alt = p.nom;
zone.appendChild(img);
} else {
var past = document.createElement("span");
past.className = "piece-fichier";
past.textContent = p.nom;
zone.appendChild(past);
}
});
el.appendChild(zone);
}
sendBtn.disabled = true; sendBtn.disabled = true;
var attente = ajouterMsg("agent", "Spark réfléchit…"); var attente = ajouterMsg("agent", "Spark réfléchit…");
var corps = { message: texte, domain: domaine(), conversation_id: convId };
if (pieces.length) {
corps.pieces = pieces.map(function (p) {
return { nom: p.nom, type: p.type, donnees_base64: p.base64 };
});
}
fetch(API + "/api/v1/chat", { fetch(API + "/api/v1/chat", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId }) body: JSON.stringify(corps)
}) })
.then(function (r) { .then(function (r) {
if (r.status === 401) { if (r.status === 401) {
@@ -831,11 +1139,12 @@
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie."; attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
majEtat("err", "Service d'agents injoignable"); majEtat("err", "Service d'agents injoignable");
}) })
.finally(function () { sendBtn.disabled = false; inputEl.focus(); }); .finally(function () { sendBtn.disabled = false; majComposer(); inputEl.focus(); });
} }
function ouvrir() { function ouvrir() {
if (!root) { construire(); demarrerDiscussions(); } if (!root) { construire(); demarrerDiscussions(); }
else resynchroniserDomaine();
root.classList.add("open"); root.classList.add("open");
appliquerTheme(); appliquerTheme();
verifierIdentite(); verifierIdentite();
+9 -6
View File
@@ -76,6 +76,7 @@
</style> </style>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/draco-barre.css" /> <link rel="stylesheet" href="/static/draco-barre.css" />
<link rel="stylesheet" href="/static/draco-alertes.css" />
<link rel="stylesheet" href="/static/draco-panneau.css" /> <link rel="stylesheet" href="/static/draco-panneau.css" />
<link <link
rel="stylesheet" rel="stylesheet"
@@ -332,7 +333,7 @@
<!-- Main Application (wrapped for show/hide) --> <!-- Main Application (wrapped for show/hide) -->
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1). <!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4 Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.6
(ui/barre-apps/) : ne pas modifier la structure ici ; toute (ui/barre-apps/) : ne pas modifier la structure ici ; toute
évolution passe par une nouvelle version de commons. évolution passe par une nouvelle version de commons.
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. --> CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
@@ -386,8 +387,8 @@
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span> <span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span> <span class="draco-badge" data-badge="spark" hidden>0</span>
</button> </button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button> <button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
<span class="draco-avatar" id="draco-avatar" title="">✦</span> <button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
</div> </div>
</header> </header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div> <div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
@@ -395,8 +396,9 @@
/* Configuration ObsiGate de la barre commune (Fusion #215). /* Configuration ObsiGate de la barre commune (Fusion #215).
Phase 1 : navigation entre apps et domaine mémorisé Phase 1 : navigation entre apps et domaine mémorisé
localement ; la recherche focus la recherche native localement ; la recherche focus la recherche native
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la d'ObsiGate ; Alertes unifiées : panneau draco-alertes.js
fédération / l'agent commun (phases suivantes). */ (Fusion #69, agrégation à la demande, pas une fédération
temps réel) ; Spark via le panneau commun. */
/* #223 : les href des onglets portent les défauts officiels ; /* #223 : les href des onglets portent les défauts officiels ;
l'hôte les réécrit depuis window.DRACO_URLS (injecté par le l'hôte les réécrit depuis window.DRACO_URLS (injecté par le
backend depuis sa configuration) quand il est présent. */ backend depuis sa configuration) quand il est présent. */
@@ -433,7 +435,7 @@
} }
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; } if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; } if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; } if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { dire("Alertes unifiées : le module n'a pas pu se charger."); } return; }
if (act === "spark") { if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); } if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); } else { dire("Spark : le panneau n'a pas pu se charger."); }
@@ -6065,6 +6067,7 @@ curl -X POST https://votre-serveur.com/webhook \
</div> </div>
</div> </div>
</div> </div>
<script src="/static/draco-alertes.js"></script>
<script defer src="/static/draco-barre.js"></script> <script defer src="/static/draco-barre.js"></script>
<script defer src="/static/draco-liens.js"></script> <script defer src="/static/draco-liens.js"></script>
<script defer src="/static/draco-panneau.js"></script> <script defer src="/static/draco-panneau.js"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.81.1", "version": "2.81.3",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js", "main": "patch.js",
"directories": { "directories": {
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* ObsiGate — Alertes unifiées (Fusion #69, chantier 2).
*
* Tests du module partagé frontend/draco-alertes.js (logique pure,
* sans DOM) + vérification du gabarit livré frontend/index.html :
* le placeholder « Alertes unifiées : arrive avec la fédération »
* a disparu et le module est chargé.
*
* Usage: node tests/frontend/draco-alertes.test.mjs
*/
import test from "node:test";
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const require = createRequire(import.meta.url);
const t = require(path.join(REPO_ROOT, "frontend", "draco-alertes.js"));
test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
assert.equal(
t.resolveUrls({ hostname: "og.dracodev.net", tabHrefs: {} }).tiktak,
"https://tiktak.dracodev.net/"
);
assert.equal(
t.resolveUrls({ hostname: "og-dev.dracodev.net", tabHrefs: {} }).flowdeck,
"https://flowdeck-dev.dracodev.net/"
);
assert.equal(
t.resolveUrls({
hostname: "og-dev.dracodev.net",
configUrls: { kanbante: "https://kanbante.dracodev.net/" },
}).kanbante,
"https://kanbante.dracodev.net/"
);
assert.equal(t.isDevHost("og-dev.dracodev.net.evil.com"), false);
});
test("normalizeFlowdeck/TikTak/Kanbante: actifs = non lus, Kanbante jamais actif", () => {
const fd = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
notifications: [
{ id: 1, ntype: "mention", title: "Mention", message: "m", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
],
unread: 1,
});
assert.equal(fd[0].active, true);
assert.equal(fd[0].url, "https://flowdeck.dracodev.net/collections/3");
const tt = t.normalizeTiktak("https://tiktak.dracodev.net/", [
{ id: 7, kind: "reminder", title: "Rappel", body: "", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
]);
assert.equal(tt[0].url, "https://tiktak.dracodev.net/?task=42");
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
],
});
assert.equal(kb[0].active, false);
assert.equal(kb[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
assert.equal(t.countActive([...fd, ...tt, ...kb]), 2);
});
test("sortAlerts: actives d'abord, date decroissante", () => {
const sorted = t.sortAlerts([
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
]);
assert.equal(sorted[0].active, true);
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
});
test("index.html: placeholder Alertes disparu, module charge", () => {
const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8");
assert.equal(html.includes("Alertes unifiées : arrive avec la fédération"), false);
assert.equal(html.includes("/static/draco-alertes.js"), true);
assert.equal(html.includes("/static/draco-alertes.css"), true);
assert.equal(html.includes("DRACO_ALERTES.open()"), true);
});