Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75139fbf6b | ||
|
|
3a4ee20d74 | ||
|
|
92e636477f | ||
|
|
67a486313f | ||
|
|
1d6ea4728f | ||
|
|
d134ad49f1 | ||
|
|
e0b39c6570 | ||
|
|
ec20573de0 | ||
|
|
3b8402374a | ||
|
|
6635defb2e | ||
|
|
7fb073d462 | ||
|
|
2e59f8786b | ||
|
|
ba6069962a | ||
|
|
375de59e28 | ||
|
|
3b84a86b8f | ||
|
|
a5dac2349d | ||
|
|
da6de52198 | ||
|
|
87016e5581 | ||
|
|
e0e45ac048 | ||
|
|
1b3ba04b5b | ||
|
|
5c5180336b | ||
|
|
73bf863e40 | ||
|
|
06d0e354fe | ||
|
|
a01fba6602 | ||
|
|
54ebf2365c | ||
|
|
24eaa7f9df | ||
|
|
8e3eb3a9d7 | ||
|
|
01ce8b8ec3 | ||
|
|
5dd6809ef0 | ||
|
|
7b8bc52650 | ||
|
|
1758c3b6d2 | ||
|
|
6669633902 | ||
|
|
a65d282c0b | ||
|
|
524b43cc40 | ||
|
|
4e0b27c4d7 | ||
|
|
c6d7acfd99 | ||
|
|
bf941c71ec | ||
|
|
dcec510451 | ||
|
|
cb468b0f3c | ||
|
|
6713510ce9 | ||
|
|
bf5b32de16 | ||
|
|
2a1780b43d | ||
|
|
be05ec99b5 | ||
|
|
3a42bc034d | ||
|
|
27e7664ef0 | ||
|
|
ccbf1d3269 | ||
|
|
0ca14739f9 | ||
|
|
d9192ef355 | ||
|
|
4c9bfd576c |
+21
-1
@@ -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).
|
||||
|
||||
> **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.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.81.3**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,26 @@ 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.0] — 2026-10-28
|
||||
|
||||
---
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.81.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.81.1 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.81.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.81.1 | Last updated: September 2026*
|
||||
|
||||
@@ -521,6 +521,16 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
|
||||
# après le CORSMiddleware global donc exécutée avant lui (dernier
|
||||
# ajouté = le plus externe) : les preflights OPTIONS sont répondus
|
||||
# ici, et les réponses de /api/search reçoivent les en-têtes sœurs.
|
||||
# La politique same-origin explicite du middleware global reste la
|
||||
# règle par défaut pour tout le reste.
|
||||
from backend.recherche_cors import RechercheCorsMiddleware
|
||||
|
||||
app.add_middleware(RechercheCorsMiddleware)
|
||||
|
||||
# Auth router
|
||||
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
|
||||
from backend.ai_routes import router as ai_router
|
||||
@@ -940,6 +950,71 @@ if FRONTEND_DIR.exists():
|
||||
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Admin page not found")
|
||||
|
||||
@app.get("/recherche", response_class=HTMLResponse)
|
||||
async def serve_recherche_page(request: Request):
|
||||
"""Page « Rechercher partout » (fusion #68) — déclarée AVANT le
|
||||
catch-all SPA comme /admin.html, sinon index.html serait servi.
|
||||
|
||||
frontend/recherche.html est servi avec les jetons {{URL_*}}
|
||||
remplacés par urls_publiques() (même contrat que le fragment
|
||||
de barre) et le nonce CSP frais ; la réponse pose sa propre
|
||||
CSP (préservée par SecurityHeadersMiddleware, qui ne la pose
|
||||
que si elle est absente) dont le connect-src autorise les
|
||||
origines sœurs — sinon le navigateur bloquerait les appels
|
||||
sœurs avant même CORS. La CSP par défaut (same-origin) reste
|
||||
inchangée pour toutes les autres pages.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from backend.csp import inject_csp_nonce
|
||||
from backend.draco_config import urls_publiques
|
||||
from backend.recherche_cors import origin_of
|
||||
|
||||
recherche_file = FRONTEND_DIR / "recherche.html"
|
||||
if not recherche_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Recherche page not found")
|
||||
html = recherche_file.read_text(encoding="utf-8")
|
||||
urls = urls_publiques()
|
||||
for jeton, cle in (
|
||||
("{{URL_OBSIGATE}}", "obsigate"),
|
||||
("{{URL_FLOWDECK}}", "flowdeck"),
|
||||
("{{URL_TIKTAK}}", "tiktak"),
|
||||
("{{URL_KANBANTE}}", "kanbante"),
|
||||
):
|
||||
html = html.replace(jeton, urls.get(cle, ""))
|
||||
script = (
|
||||
"<script>window.DRACO_URLS = "
|
||||
+ _json.dumps(urls).replace("<", "\\u003c")
|
||||
+ ";</script>"
|
||||
)
|
||||
if "</head>" in html:
|
||||
html = html.replace("</head>", script + "</head>", 1)
|
||||
else:
|
||||
html = script + html
|
||||
html = inject_csp_nonce(html, request.state.csp_nonce)
|
||||
soeurs = " ".join(sorted({
|
||||
origin_of(urls.get(cle, "")) or ""
|
||||
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
} - {""}))
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
f"script-src 'self' 'nonce-{request.state.csp_nonce}'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
f"connect-src 'self' {soeurs}; "
|
||||
"font-src 'self' data:; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'self';"
|
||||
)
|
||||
return HTMLResponse(
|
||||
content=html,
|
||||
headers={"Cache-Control": "no-cache", "Content-Security-Policy": csp},
|
||||
)
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(request: Request, full_path: str):
|
||||
"""Serve the SPA index.html for all non-API routes."""
|
||||
@@ -947,3 +1022,4 @@ if FRONTEND_DIR.exists():
|
||||
if index_file.exists():
|
||||
return HTMLResponse(content=_index_html(request), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Frontend not found")
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ TAGS_METADATA: list[dict[str, str]] = [
|
||||
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
||||
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
||||
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
|
||||
{"name": "Coffres Secrets", "description": "Secret vaults: encrypted coffres, secrets, consumers, grants and distribution — metadata only, values are never exposed."},
|
||||
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
|
||||
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
||||
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""CORS bornée pour la recherche fédérée (fusion-trois-apps #68).
|
||||
|
||||
La page « Rechercher partout » (``/recherche``) appelle, depuis le
|
||||
navigateur, ``GET /api/search`` sur chaque application sœur. Sans
|
||||
en-têtes CORS, le navigateur refuse la lecture des réponses
|
||||
cross-origin ; sans borne, on ouvrirait la lecture à tout le web.
|
||||
Ce module n'autorise donc que :
|
||||
|
||||
- le chemin ``/api/search`` (jamais les autres routes, jamais les
|
||||
écritures) ;
|
||||
- les origines sœurs : celles de ``backend.draco_config``
|
||||
(``urls_publiques()`` : explicite > dérivation par l'hôte de
|
||||
l'agent > défaut officiel), **plus** les origines prod ou ``-dev``
|
||||
officielles correspondant à l'hôte de la requête — ce second
|
||||
ensemble couvre le cas où la configuration seule ne déclare pas
|
||||
l'environnement courant ;
|
||||
- avec credentials (le cookie SSO ``draco_session``, accepté par
|
||||
``/api/search`` via le repli de ``backend.auth.middleware``, ou
|
||||
le cookie natif ``access_token``).
|
||||
|
||||
Jamais ``*``. Le middleware est un ASGI pur : il répond lui-même
|
||||
aux preflights OPTIONS (qui ne portent jamais de cookies) et ajoute
|
||||
les en-têtes aux réponses de recherche, sans toucher aux autres
|
||||
routes ni à la politique same-origin explicite du CORSMiddleware
|
||||
global (``allow_origins=[]``), qui reste la règle par défaut.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SEARCH_PATH = "/api/search"
|
||||
|
||||
PROD_ORIGINS = frozenset({
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
})
|
||||
|
||||
DEV_ORIGINS = frozenset({
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
})
|
||||
|
||||
# Clés de draco_config.urls_publiques() qui sont des applications sœurs.
|
||||
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
|
||||
|
||||
def is_dev_host(hostname: str | None) -> bool:
|
||||
"""Même règle que la barre v0.1.6 (voir frontend/js/recherche.js)."""
|
||||
if not hostname:
|
||||
return False
|
||||
h = hostname.lower().split(":")[0]
|
||||
if not h.endswith(".dracodev.net"):
|
||||
return False
|
||||
first = h.split(".")[0]
|
||||
return len(first) > 4 and first.endswith("-dev")
|
||||
|
||||
|
||||
def is_prod_host(hostname: str | None) -> bool:
|
||||
"""Hôte dracodev.net qui n'est ni ``-dev`` ni un piège de suffixe."""
|
||||
if not hostname:
|
||||
return False
|
||||
h = hostname.lower().split(":")[0]
|
||||
if not h.endswith(".dracodev.net"):
|
||||
return False
|
||||
first = h.split(".")[0]
|
||||
return len(first) > 0 and not first.endswith("-dev")
|
||||
|
||||
|
||||
def origin_of(url: str | None) -> str | None:
|
||||
"""Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final."""
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return None
|
||||
host = parsed.hostname.lower()
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
return f"{parsed.scheme}://{host}{port}"
|
||||
|
||||
|
||||
def sister_origins(resolved: dict | None = None, request_host: str | None = None) -> frozenset:
|
||||
"""Origines autorisées pour la lecture cross-origin de /api/search.
|
||||
|
||||
``resolved`` : dictionnaire rendu par ``urls_publiques()``.
|
||||
Sans argument, le résolveur est appelé (import paresseux) ; en cas
|
||||
d'échec, repli sur prod + dev officiels (premier parti uniquement).
|
||||
``request_host`` ajoute l'ensemble officiel de l'environnement de
|
||||
la requête (voir le docstring du module).
|
||||
"""
|
||||
origins = set()
|
||||
if resolved is None:
|
||||
try:
|
||||
from backend.draco_config import urls_publiques
|
||||
|
||||
resolved = urls_publiques()
|
||||
except Exception:
|
||||
resolved = {}
|
||||
for key in _SOEUR_KEYS:
|
||||
origin = origin_of((resolved or {}).get(key))
|
||||
if origin:
|
||||
origins.add(origin)
|
||||
if is_dev_host(request_host):
|
||||
origins |= DEV_ORIGINS
|
||||
elif is_prod_host(request_host):
|
||||
origins |= PROD_ORIGINS
|
||||
if not origins:
|
||||
return PROD_ORIGINS | DEV_ORIGINS
|
||||
return frozenset(origins)
|
||||
|
||||
|
||||
def cors_headers_for(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes CORS d'une réponse de recherche, ou None si refusée."""
|
||||
if not origin:
|
||||
return None
|
||||
if origin.strip() not in sister_origins(resolved, request_host):
|
||||
return None
|
||||
return {
|
||||
"Access-Control-Allow-Origin": origin.strip(),
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
|
||||
|
||||
def preflight_headers(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes d'une réponse de preflight OPTIONS pour /api/search."""
|
||||
headers = cors_headers_for(origin, resolved, request_host)
|
||||
if headers is None:
|
||||
return None
|
||||
headers.update({
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Accept, Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "600",
|
||||
})
|
||||
return headers
|
||||
|
||||
|
||||
class RechercheCorsMiddleware:
|
||||
"""ASGI pur : CORS bornée pour ``GET /api/search`` (voir module)."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
headers_in = {
|
||||
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope.get("headers", [])
|
||||
}
|
||||
origin = headers_in.get("origin", "")
|
||||
request_host = (
|
||||
headers_in.get("x-forwarded-host", "") or headers_in.get("host", "")
|
||||
).split(",")[0].strip()
|
||||
if path == SEARCH_PATH and scope.get("method") == "OPTIONS":
|
||||
preflight = preflight_headers(origin, request_host=request_host)
|
||||
if preflight is not None:
|
||||
headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in preflight.items()]
|
||||
headers_out.append((b"content-length", b"0"))
|
||||
await send({"type": "http.response.start", "status": 204,
|
||||
"headers": headers_out})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
return
|
||||
if path != SEARCH_PATH:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_with_cors(message):
|
||||
if message["type"] == "http.response.start":
|
||||
cors = cors_headers_for(origin, request_host=request_host)
|
||||
if cors is not None:
|
||||
message["headers"] = list(message.get("headers", [])) + [
|
||||
(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in cors.items()
|
||||
]
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_cors)
|
||||
@@ -34,10 +34,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
|
||||
from backend.audit import get_recent_entries, log_coffre_event
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.coffre_crypto import (
|
||||
CryptoIndisponibleError,
|
||||
KEKAbsenteError,
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.81.0"
|
||||
version = "2.81.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.81.0"
|
||||
version = "2.81.3"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.81.0",
|
||||
"version": "2.81.3",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.81.0 | **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
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
||||
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) ;
|
||||
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.
|
||||
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. */
|
||||
@@ -42,6 +44,29 @@
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
.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 {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 10px;
|
||||
@@ -122,8 +147,10 @@
|
||||
}
|
||||
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||
.draco-avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
||||
background: #2f6fed; color: #fff; font-weight: 700;
|
||||
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
|
||||
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) */
|
||||
|
||||
+403
-40
@@ -1,5 +1,10 @@
|
||||
/* 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
|
||||
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||
@@ -10,19 +15,158 @@
|
||||
* current: "tiktak", // app courante
|
||||
* domain: "pro", // domaine actif
|
||||
* 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
|
||||
* } // 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),
|
||||
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
|
||||
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
|
||||
* des applications, construit depuis les onglets ; c'est le moyen de
|
||||
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
|
||||
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
|
||||
* ouvre le menu des applications, construit depuis les onglets ;
|
||||
* 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 () {
|
||||
"use strict";
|
||||
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) {
|
||||
Object.keys(counts || {}).forEach(function (k) {
|
||||
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||
@@ -43,26 +187,122 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 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). */
|
||||
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
|
||||
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
|
||||
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
|
||||
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
|
||||
* ✦ 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 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() {
|
||||
if (!bar || !brand || menu) return;
|
||||
menu = document.createElement("div");
|
||||
menu.className = "draco-apps-menu";
|
||||
menu.hidden = true;
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "Applications");
|
||||
function setExpanded(btn, open) {
|
||||
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
function refreshMenuHrefs(menu) {
|
||||
if (!menu) return;
|
||||
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
|
||||
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) {
|
||||
var a = document.createElement("a");
|
||||
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.setAttribute("role", "menuitem");
|
||||
var ico = t.querySelector(".draco-apptab-ico");
|
||||
@@ -84,44 +324,167 @@
|
||||
a.appendChild(txt);
|
||||
var badge = t.querySelector(".draco-badge");
|
||||
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) {
|
||||
buildMenu();
|
||||
if (!menu) return;
|
||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
||||
menu.hidden = !open;
|
||||
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
/* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
|
||||
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
|
||||
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
|
||||
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
|
||||
function buildMoreMenu() {
|
||||
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) {
|
||||
var el = ev.target.closest("[data-act]");
|
||||
if (el && el.dataset.act === "apps") {
|
||||
ev.preventDefault();
|
||||
toggleMenu();
|
||||
/* Items du menu compte : relais + fermeture, jamais de navigation. */
|
||||
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
|
||||
if (compteItem) {
|
||||
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
|
||||
toggleCompteMenu(false);
|
||||
return;
|
||||
}
|
||||
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
|
||||
toggleMenu(false);
|
||||
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
|
||||
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;
|
||||
var act = el.dataset.act;
|
||||
if (act === "domaine") setDomain(el.dataset.dom);
|
||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||
relay(act, el, 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. */
|
||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
||||
/* API publique : l'app met les compteurs à jour sans recharger.
|
||||
* 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.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
|
||||
+89
-17
@@ -37,16 +37,19 @@
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
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-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-panneau-titres { min-width: 0; flex: 1; }
|
||||
.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 {
|
||||
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) */
|
||||
.draco-disc-bar {
|
||||
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.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-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
|
||||
.draco-panneau-form { display: flex; gap: 8px; }
|
||||
.draco-panneau-form input {
|
||||
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
||||
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
||||
.draco-panneau-ft { padding: 10px 12px 11px; background: var(--p-fond); }
|
||||
/* Puces de suggestions, comme la console : au-dessus du composer. */
|
||||
.draco-puces {
|
||||
display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; padding: 0 2px 8px;
|
||||
}
|
||||
.draco-panneau-form button {
|
||||
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
||||
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
||||
.draco-puces button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
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 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; }
|
||||
|
||||
+332
-23
@@ -40,7 +40,10 @@
|
||||
var convKind = null; // 'principale' | 'parallele'
|
||||
var convTitre = "";
|
||||
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 microEl = null, piecesEl = null, fichierEl = null;
|
||||
var titreEl = null, tiroirEl = null;
|
||||
var versionEl = null, versionConnue = "", versionMaj = 0;
|
||||
|
||||
@@ -68,11 +71,16 @@
|
||||
return correspondance[h] || null;
|
||||
}
|
||||
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 id = convId || convStockee();
|
||||
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||
var app = appHote();
|
||||
if (app) params.push("app=" + encodeURIComponent(app));
|
||||
params.push("domain=" + encodeURIComponent(domaine()));
|
||||
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||
}
|
||||
|
||||
@@ -147,7 +155,15 @@
|
||||
|
||||
function ouvrirConsole() {
|
||||
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;
|
||||
surOuverte = true;
|
||||
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||
@@ -175,7 +191,12 @@
|
||||
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||
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(); }
|
||||
|
||||
/* ── 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-panneau-hd">' +
|
||||
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||
' <div><div class="titre">Spark — agent commun</div>' +
|
||||
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
||||
' <div class="draco-panneau-titres"><div class="titre">Spark — agent commun</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>' +
|
||||
'</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">' +
|
||||
' <span class="d-titre">Discussion principale</span>' +
|
||||
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||
@@ -266,17 +284,40 @@
|
||||
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-panneau-msgs"></div>' +
|
||||
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
|
||||
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
||||
' <button type="submit">Envoyer</button></form>' +
|
||||
' <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>' +
|
||||
'<div class="draco-panneau-ft">' +
|
||||
' <div class="draco-puces">' +
|
||||
' <button type="button" data-sugg="resume">Résumer ma journée</button>' +
|
||||
' <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>';
|
||||
document.body.appendChild(root);
|
||||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||
etatEl = root.querySelector(".draco-panneau-etat");
|
||||
inputEl = root.querySelector("input");
|
||||
sendBtn = root.querySelector("button[type=submit]");
|
||||
etatEl = root.querySelector(".draco-panneau-hd .soustitre");
|
||||
inputEl = root.querySelector(".draco-composer textarea");
|
||||
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");
|
||||
tiroirEl = root.querySelector(".draco-discussions");
|
||||
versionEl = root.querySelector(".draco-panneau-version");
|
||||
@@ -288,6 +329,42 @@
|
||||
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
||||
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||
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"));
|
||||
appliquerTheme();
|
||||
surveillerTheme();
|
||||
@@ -326,8 +403,13 @@
|
||||
}
|
||||
|
||||
function majEtat(cls, texte) {
|
||||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
||||
etatEl.querySelector(".txt").textContent = texte;
|
||||
/* Statut intégré au titre (sous-titre de l'en-tête) : plus de
|
||||
* 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() {
|
||||
titreEl.textContent = convTitre || "Discussion principale";
|
||||
@@ -400,9 +482,21 @@
|
||||
.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() {
|
||||
chargerListeDiscussions()
|
||||
.then(function (d) {
|
||||
domaineCharge = domaine();
|
||||
var precedente = convStockee();
|
||||
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||
@@ -413,6 +507,7 @@
|
||||
chargerHistorique();
|
||||
})
|
||||
.catch(function () {
|
||||
domaineCharge = null; // échec : la prochaine ouverture réessaiera
|
||||
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||
});
|
||||
}
|
||||
@@ -780,19 +875,232 @@
|
||||
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||
}
|
||||
|
||||
function envoyer(ev) {
|
||||
ev.preventDefault();
|
||||
var texte = inputEl.value.trim();
|
||||
if (!texte) return;
|
||||
/* ── Composer façon console ────────────────────────────────
|
||||
* Mêmes règles que la console : + (images/documents, 4 max),
|
||||
* textarea auto-extensible, micro affiché seulement sans texte
|
||||
* 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 = "";
|
||||
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;
|
||||
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", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
|
||||
body: JSON.stringify(corps)
|
||||
})
|
||||
.then(function (r) {
|
||||
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.";
|
||||
majEtat("err", "Service d'agents injoignable");
|
||||
})
|
||||
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
|
||||
.finally(function () { sendBtn.disabled = false; majComposer(); inputEl.focus(); });
|
||||
}
|
||||
|
||||
function ouvrir() {
|
||||
if (!root) { construire(); demarrerDiscussions(); }
|
||||
else resynchroniserDomaine();
|
||||
root.classList.add("open");
|
||||
appliquerTheme();
|
||||
verifierIdentite();
|
||||
|
||||
+4
-4
@@ -332,7 +332,7 @@
|
||||
|
||||
<!-- Main Application (wrapped for show/hide) -->
|
||||
<!-- 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
|
||||
évolution passe par une nouvelle version de commons.
|
||||
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
|
||||
@@ -386,8 +386,8 @@
|
||||
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
||||
<span class="draco-avatar" id="draco-avatar" title="">✦</span>
|
||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
|
||||
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||
@@ -431,7 +431,7 @@
|
||||
if (b) b.click();
|
||||
return;
|
||||
}
|
||||
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
|
||||
if (act === "palette") { location.href = "/recherche"; 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 === "spark") {
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
|
||||
*
|
||||
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
|
||||
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
|
||||
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
|
||||
*
|
||||
* Contrat d'API (audité, rien d'inventé) :
|
||||
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
|
||||
* -> { results: [{ vault, path, title, snippet }] }
|
||||
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
|
||||
* - Flowdeck : GET {base}/api/search?q=
|
||||
* -> { pages: [{ title, excerpt, subtitle, url: "/pages/<id>" }],
|
||||
* collections: [{ title, subtitle, url: "/db/<id>" }] }
|
||||
* - TikTak : GET {base}/api/search?q=
|
||||
* -> { tasks: [{ id, title, description, status, due_at }], ... }
|
||||
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
|
||||
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
|
||||
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
|
||||
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
|
||||
* adressable — même limite que la page /search native de Kanbante).
|
||||
*
|
||||
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
|
||||
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
|
||||
* hôte de la requête > dérivation > défaut officiel) ;
|
||||
* 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ;
|
||||
* 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ;
|
||||
* 4. href des onglets .draco-apptab rendus ;
|
||||
* 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev).
|
||||
*
|
||||
* Authentification cross-origin : les appels sœurs partent avec
|
||||
* credentials:"include". Le navigateur envoie alors les cookies de
|
||||
* chaque origine, dont le cookie SSO partagé draco_session
|
||||
* (Domain=.dracodev.net), accepté par les 4 API de recherche
|
||||
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette
|
||||
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
|
||||
* (GET sur l'endpoint de recherche uniquement, pas de "*").
|
||||
* Une sœur injoignable / non autorisée ne casse pas les autres :
|
||||
* sa section affiche l'erreur + un lien vers sa recherche native.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
/* globalThis : le fichier est chargé comme script classique dans les
|
||||
* navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck,
|
||||
* et importé en ESM par le test ObsiGate (frontend/js est "type":
|
||||
* "module") — où `this` de niveau supérieur vaut undefined. */
|
||||
|
||||
var PROD = {
|
||||
obsigate: "https://og.dracodev.net/",
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
kanbante: "https://kanbante.dracodev.net/",
|
||||
};
|
||||
var DEV = {
|
||||
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 APPS = [
|
||||
{ key: "obsigate", label: "ObsiGate" },
|
||||
{ key: "flowdeck", label: "Flowdeck" },
|
||||
{ key: "tiktak", label: "TikTak" },
|
||||
{ key: "kanbante", label: "Kanbante" },
|
||||
];
|
||||
var COLUMN_LABELS = {
|
||||
backlog: "Backlog",
|
||||
in_progress: "En cours",
|
||||
qa: "QA",
|
||||
review: "Relecture",
|
||||
todo: "À faire",
|
||||
done: "Terminé",
|
||||
};
|
||||
var TIMEOUT_MS = 8000;
|
||||
var DEBOUNCE_MS = 250;
|
||||
var PER_APP_LIMIT = 8;
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanUrl(u) {
|
||||
if (!u) return null;
|
||||
u = String(u).trim();
|
||||
if (!u || u.indexOf("{{") !== -1) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
function withSlash(u) {
|
||||
return u.charAt(u.length - 1) === "/" ? u : u + "/";
|
||||
}
|
||||
|
||||
/* Même règle que la barre v0.1.6 : première étiquette en -dev ET
|
||||
* domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */
|
||||
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";
|
||||
}
|
||||
|
||||
/* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */
|
||||
function resolveBases(opts) {
|
||||
opts = opts || {};
|
||||
var dev = isDevHost(opts.host);
|
||||
var out = {};
|
||||
APPS.forEach(function (app) {
|
||||
var key = app.key;
|
||||
var u = cleanUrl(opts.explicit && opts.explicit[key]);
|
||||
if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]);
|
||||
if (!u && dev) u = DEV[key];
|
||||
if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]);
|
||||
if (!u) u = PROD[key];
|
||||
out[key] = withSlash(u);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function searchPath(app, q) {
|
||||
if (app === "obsigate") {
|
||||
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
|
||||
}
|
||||
return "api/search?q=" + encodeURIComponent(q);
|
||||
}
|
||||
|
||||
function snippetOf(s, n) {
|
||||
s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
|
||||
return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s;
|
||||
}
|
||||
|
||||
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
|
||||
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
|
||||
function normalize(app, data, base, q) {
|
||||
data = data || {};
|
||||
if (app === "obsigate") {
|
||||
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
|
||||
return {
|
||||
title: r.title || r.path || "Sans titre",
|
||||
snippet: snippetOf(r.snippet || ""),
|
||||
context: (r.vault || "") + (r.path ? " · " + r.path : ""),
|
||||
url:
|
||||
base +
|
||||
"#file=" +
|
||||
encodeURIComponent(r.vault || "") +
|
||||
":" +
|
||||
encodeURIComponent(r.path || ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (app === "flowdeck") {
|
||||
var items = (data.pages || []).concat(data.collections || []);
|
||||
return items.slice(0, PER_APP_LIMIT).map(function (r) {
|
||||
var rel = String(r.url || "").replace(/^\//, "");
|
||||
return {
|
||||
title: r.title || "Sans titre",
|
||||
snippet: snippetOf(r.excerpt || ""),
|
||||
context: r.subtitle || (r.type === "collection" ? "Base" : "Page"),
|
||||
url: base + rel,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (app === "tiktak") {
|
||||
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
|
||||
var ctx = t.status === "done" ? "Terminée" : "Active";
|
||||
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
|
||||
return {
|
||||
title: t.title || "Sans titre",
|
||||
snippet: snippetOf(t.description || ""),
|
||||
context: "Tâche · " + ctx,
|
||||
url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (app === "kanbante") {
|
||||
var rows = [];
|
||||
(data.groups || []).forEach(function (g) {
|
||||
(g.rows || []).forEach(function (row) {
|
||||
rows.push({ group: g.full_name, row: row });
|
||||
});
|
||||
});
|
||||
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
|
||||
var issue = x.row.issue || {};
|
||||
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
|
||||
return {
|
||||
title: "#" + issue.number + " " + (issue.title || "Sans titre"),
|
||||
snippet: snippetOf(issue.body || ""),
|
||||
context: x.group + (col ? " · " + col : ""),
|
||||
url: base + "board/" + x.group,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/* Recherche native de l'app (repli quand la section est en erreur). */
|
||||
function nativeSearchUrl(app, base, q) {
|
||||
if (app === "tiktak" || app === "kanbante") {
|
||||
return base + "search?q=" + encodeURIComponent(q || "");
|
||||
}
|
||||
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
|
||||
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
|
||||
return base;
|
||||
}
|
||||
|
||||
function nativeSearchLabel(app) {
|
||||
if (app === "tiktak" || app === "kanbante") {
|
||||
return "Ouvrir la recherche dans " + appLabel(app);
|
||||
}
|
||||
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
|
||||
}
|
||||
|
||||
function appLabel(key) {
|
||||
for (var i = 0; i < APPS.length; i++) {
|
||||
if (APPS[i].key === key) return APPS[i].label;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
var api = {
|
||||
esc: esc,
|
||||
isDevHost: isDevHost,
|
||||
resolveBases: resolveBases,
|
||||
searchPath: searchPath,
|
||||
normalize: normalize,
|
||||
nativeSearchUrl: nativeSearchUrl,
|
||||
PROD: PROD,
|
||||
DEV: DEV,
|
||||
};
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
global.RecherchePartout = api;
|
||||
|
||||
/* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
function fetchSection(app, url, onDone) {
|
||||
var ctrl = null;
|
||||
var timer = null;
|
||||
try {
|
||||
ctrl = new AbortController();
|
||||
timer = setTimeout(function () {
|
||||
ctrl.abort();
|
||||
}, TIMEOUT_MS);
|
||||
} catch (e) {
|
||||
ctrl = null;
|
||||
}
|
||||
var opts = { headers: { Accept: "application/json" }, credentials: "include" };
|
||||
if (ctrl) opts.signal = ctrl.signal;
|
||||
fetch(url, opts)
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
var err = new Error("HTTP " + resp.status);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (timer) clearTimeout(timer);
|
||||
onDone(null, data);
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (timer) clearTimeout(timer);
|
||||
onDone(err, null);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSection(section, state) {
|
||||
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
|
||||
var body = section.querySelector(".rp-body");
|
||||
var count = section.querySelector(".rp-count");
|
||||
var toutVoir = section.querySelector(".rp-all");
|
||||
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
|
||||
toutVoir.textContent = "Tout voir";
|
||||
toutVoir.title = nativeSearchLabel(state.app);
|
||||
if (state.status === "loading") {
|
||||
count.textContent = "";
|
||||
body.innerHTML = '<p class="rp-state">Recherche…</p>';
|
||||
return;
|
||||
}
|
||||
if (state.status === "error") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state rp-error">' +
|
||||
esc(state.message) +
|
||||
' — <a href="' +
|
||||
esc(nativeSearchUrl(state.app, state.base, state.q)) +
|
||||
'">' +
|
||||
esc(nativeSearchLabel(state.app)) +
|
||||
"</a></p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "empty") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
|
||||
return;
|
||||
}
|
||||
count.textContent = String(state.items.length);
|
||||
body.innerHTML = state.items
|
||||
.map(function (item) {
|
||||
return (
|
||||
'<a class="rp-item" data-rp-link href="' +
|
||||
esc(item.url) +
|
||||
'">' +
|
||||
'<span class="rp-title">' +
|
||||
esc(item.title) +
|
||||
"</span>" +
|
||||
(item.context
|
||||
? '<span class="rp-context">' + esc(item.context) + "</span>"
|
||||
: "") +
|
||||
(item.snippet
|
||||
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
|
||||
: "") +
|
||||
"</a>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function init() {
|
||||
var root = document.getElementById("recherche");
|
||||
if (!root) return;
|
||||
var input = document.getElementById("rp-q");
|
||||
if (!input) return;
|
||||
var hostApp = root.getAttribute("data-host") || "";
|
||||
var explicit = {};
|
||||
APPS.forEach(function (app) {
|
||||
var v = root.getAttribute("data-base-" + app.key);
|
||||
if (v) explicit[app.key] = v;
|
||||
});
|
||||
var barUrls = {};
|
||||
try {
|
||||
if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls;
|
||||
} catch (e) {}
|
||||
var tabHrefs = {};
|
||||
try {
|
||||
document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) {
|
||||
var href = tab.getAttribute("href");
|
||||
if (href) tabHrefs[tab.getAttribute("data-app")] = href;
|
||||
});
|
||||
} catch (e) {}
|
||||
var bases = resolveBases({
|
||||
host: global.location.hostname,
|
||||
explicit: explicit,
|
||||
barUrls: barUrls,
|
||||
tabHrefs: tabHrefs,
|
||||
});
|
||||
var sections = {};
|
||||
APPS.forEach(function (app) {
|
||||
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
|
||||
if (el) sections[app.key] = el;
|
||||
});
|
||||
|
||||
var currentQ = "";
|
||||
var runId = 0;
|
||||
var debounceTimer = null;
|
||||
|
||||
function runSearch(q) {
|
||||
currentQ = q;
|
||||
var myRun = ++runId;
|
||||
if (!q) {
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.key];
|
||||
if (!sec) return;
|
||||
sec.querySelector(".rp-body").innerHTML =
|
||||
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
||||
sec.querySelector(".rp-count").textContent = "";
|
||||
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
|
||||
});
|
||||
return;
|
||||
}
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.key];
|
||||
if (!sec) return;
|
||||
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
|
||||
var url;
|
||||
if (app.key === hostApp) {
|
||||
/* Même origine : pas de CORS, pas de CSP cross-origin. */
|
||||
url = "/" + searchPath(app.key, q);
|
||||
} else {
|
||||
url = bases[app.key] + searchPath(app.key, q);
|
||||
}
|
||||
fetchSection(app.key, url, function (err, data) {
|
||||
if (myRun !== runId) return;
|
||||
if (err) {
|
||||
var msg;
|
||||
if (err.name === "AbortError") msg = "Délai dépassé (8 s)";
|
||||
else if (err.status === 401 || err.status === 403)
|
||||
msg = "Connexion requise sur " + app.label;
|
||||
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
|
||||
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
|
||||
renderSection(sec, {
|
||||
status: "error",
|
||||
message: msg,
|
||||
app: app.key,
|
||||
base: bases[app.key],
|
||||
q: q,
|
||||
});
|
||||
return;
|
||||
}
|
||||
var items = normalize(app.key, data, bases[app.key], q);
|
||||
renderSection(sec, {
|
||||
status: items.length ? "ok" : "empty",
|
||||
items: items,
|
||||
app: app.key,
|
||||
base: bases[app.key],
|
||||
q: q,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleSearch(q, immediate) {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (immediate) {
|
||||
runSearch(q);
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(function () {
|
||||
runSearch(q);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function syncUrl(q) {
|
||||
try {
|
||||
var url = new URL(global.location.href);
|
||||
if (q) url.searchParams.set("q", q);
|
||||
else url.searchParams.delete("q");
|
||||
global.history.replaceState(null, "", url.toString());
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
var q = input.value.trim();
|
||||
syncUrl(q);
|
||||
scheduleSearch(q, false);
|
||||
});
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
var first = document.querySelector("[data-rp-link]");
|
||||
if (first) {
|
||||
e.preventDefault();
|
||||
first.click();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
input.value = "";
|
||||
syncUrl("");
|
||||
scheduleSearch("", true);
|
||||
input.focus();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||
}
|
||||
});
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
function (e) {
|
||||
var target = e.target;
|
||||
var typing =
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable);
|
||||
if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
input.focus();
|
||||
input.select();
|
||||
return;
|
||||
}
|
||||
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
function moveFocus(delta, e) {
|
||||
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
|
||||
if (!links.length) return;
|
||||
var active = document.activeElement;
|
||||
var idx = links.indexOf(active);
|
||||
var next;
|
||||
if (idx === -1) next = delta > 0 ? 0 : links.length - 1;
|
||||
else next = (idx + delta + links.length) % links.length;
|
||||
e.preventDefault();
|
||||
links[next].focus();
|
||||
}
|
||||
|
||||
var params = new URLSearchParams(global.location.search);
|
||||
var initialQ = (params.get("q") || "").trim();
|
||||
if (initialQ) {
|
||||
input.value = initialQ;
|
||||
runSearch(initialQ);
|
||||
} else {
|
||||
runSearch("");
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(
|
||||
typeof window !== "undefined"
|
||||
? window
|
||||
: typeof globalThis !== "undefined"
|
||||
? globalThis
|
||||
: this
|
||||
);
|
||||
@@ -59,11 +59,11 @@ function _sharedIcon(size = 16) {
|
||||
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Icône cadenas (U+1F510) du nœud Secrets, distincte de la maison et du diamant. */
|
||||
/** Icône coffre-fort du nœud Secrets (SVG Safe Vault Streamline, fourni par Bruno), distincte de la maison et du diamant. Le tracé utilise currentColor pour rester lisible en thème clair comme sombre (le noir d'origine serait invisible en sombre). */
|
||||
function _secretsIcon() {
|
||||
const s = document.createElement("span");
|
||||
s.className = "coffres-icon-lock";
|
||||
s.textContent = "\u{1F510}";
|
||||
s.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" aria-hidden="true"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12.5 0.5h-11c-0.552285 0 -1 0.447715 -1 1V11c0 0.5523 0.447715 1 1 1h11c0.5523 0 1 -0.4477 1 -1V1.5c0 -0.552285 -0.4477 -1 -1 -1Z"/><path d="M8.5 8c0.9665 0 1.75 -0.7835 1.75 -1.75S9.4665 4.5 8.5 4.5s-1.75 0.7835 -1.75 1.75S7.5335 8 8.5 8Z"/><path d="M8.5 3.25V4.5"/><path d="M8.5 8v1.25"/><path d="M11.5 6.25h-1.25"/><path d="M6.75 6.25H5.5"/><path d="m10.62 4.12988 -0.88001 0.88"/><path d="m7.26 7.48999 -0.88 0.88"/><path d="m10.62 8.36999 -0.88001 -0.88"/><path d="m7.26 5.00988 -0.88 -0.88"/><path d="M3 4.5V8"/><path d="M2 12v1.5"/><path d="M11.5 12v1.5"/></g></svg>';
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
@@ -653,7 +653,7 @@ function applyTheme(themeKey, mode) {
|
||||
root.style.setProperty(key, vars[key]);
|
||||
});
|
||||
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
||||
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
|
||||
// from the generic surfaces so every theme (16 built-in × 4 modes, custom
|
||||
// imports) gets a matching grid instead of the hardcoded dark/light pair.
|
||||
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
|
||||
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
|
||||
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
|
||||
* autant que possible : la page reste lisible en thème clair et sombre. */
|
||||
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
|
||||
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
|
||||
.rp-input {
|
||||
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
|
||||
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
|
||||
}
|
||||
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
||||
.rp-section { margin-bottom: 28px; }
|
||||
.rp-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
|
||||
}
|
||||
.rp-head h2 { font-size: 1.05rem; margin: 0; }
|
||||
.rp-count {
|
||||
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
|
||||
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
|
||||
}
|
||||
.rp-count:empty { display: none; }
|
||||
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
|
||||
.rp-state { opacity: .75; margin: 8px 0; }
|
||||
.rp-error { opacity: 1; }
|
||||
.rp-item {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
|
||||
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
|
||||
.rp-title { display: block; font-weight: 600; }
|
||||
.rp-context { display: block; font-size: .8rem; opacity: .65; }
|
||||
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
|
||||
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rechercher partout — ObsiGate</title>
|
||||
<link rel="stylesheet" href="/static/recherche.css">
|
||||
<style>
|
||||
/* Navigation minimale (la barre complète vit dans index.html, la SPA). */
|
||||
.rp-nav { display: flex; gap: 16px; padding: 12px 16px; font-size: .9rem; }
|
||||
.rp-nav a { color: inherit; opacity: .8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Jetons {{URL_*}} remplacés par la route /recherche (même contrat
|
||||
que le fragment de barre). Servie brute via /static, la page
|
||||
retomberait sur les défauts prod (les jetons sont ignorés par
|
||||
le JS) — jamais d'URL inventée. -->
|
||||
<nav class="rp-nav">
|
||||
<a href="{{URL_OBSIGATE}}">ObsiGate</a>
|
||||
<a href="{{URL_FLOWDECK}}">Flowdeck</a>
|
||||
<a href="{{URL_TIKTAK}}">TikTak</a>
|
||||
<a href="{{URL_KANBANTE}}">Kanbante</a>
|
||||
</nav>
|
||||
<main class="rp-page" id="recherche" data-host="obsigate"
|
||||
data-base-obsigate="{{URL_OBSIGATE}}"
|
||||
data-base-flowdeck="{{URL_FLOWDECK}}"
|
||||
data-base-tiktak="{{URL_TIKTAK}}"
|
||||
data-base-kanbante="{{URL_KANBANTE}}">
|
||||
<h1>Rechercher partout</h1>
|
||||
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
|
||||
<div class="rp-form">
|
||||
<input id="rp-q" class="rp-input" type="search" name="q" value=""
|
||||
placeholder="Rechercher partout…" autocomplete="off" autofocus>
|
||||
</div>
|
||||
<section class="rp-section" data-rp-section="obsigate">
|
||||
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="flowdeck">
|
||||
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="tiktak">
|
||||
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="kanbante">
|
||||
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/js/recherche.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-1
@@ -14372,7 +14372,8 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
||||
.coffres-alerts { margin: 12px 0; }
|
||||
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
||||
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
|
||||
.coffres-icon-lock { font-size: 16px; line-height: 1; }
|
||||
.coffres-icon-lock { display: inline-flex; align-items: center; line-height: 1; }
|
||||
.coffres-icon-lock svg { width: 16px; height: 16px; flex: none; }
|
||||
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
|
||||
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
||||
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.81.0",
|
||||
"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.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — tests de la logique pure de la page « Rechercher partout »
|
||||
* (fusion #68), sans DOM ni jsdom : résolution des bases, liens
|
||||
* profonds et normalisation des 4 formats d'API.
|
||||
*
|
||||
* Le fichier frontend/js/recherche.js est chargé en ESM (frontend/js
|
||||
* est "type": "module") ; ses helpers sont exposés via
|
||||
* globalThis.RecherchePartout. Usage : node tests/frontend/recherche.test.mjs
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const JS_FILE = path.resolve(__dirname, "..", "..", "frontend", "js", "recherche.js");
|
||||
|
||||
await import(pathToFileURL(JS_FILE).href);
|
||||
const rp = globalThis.RecherchePartout;
|
||||
assert.ok(rp, "RecherchePartout exposé");
|
||||
|
||||
let n = 0;
|
||||
const ok = (cond, label) => {
|
||||
assert.ok(cond, label);
|
||||
n += 1;
|
||||
};
|
||||
|
||||
ok(rp.isDevHost("og-dev.dracodev.net") === true, "dev reconnu");
|
||||
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
|
||||
|
||||
const b = rp.resolveBases({
|
||||
host: "og-dev.dracodev.net",
|
||||
explicit: { tiktak: "https://tiktak-perso.example/" },
|
||||
barUrls: {},
|
||||
tabHrefs: {},
|
||||
});
|
||||
ok(b.tiktak === "https://tiktak-perso.example/", "explicite prioritaire");
|
||||
ok(b.obsigate === "https://og-dev.dracodev.net/", "dev pour l'hôte courant");
|
||||
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
|
||||
ok(
|
||||
rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8",
|
||||
"path ObsiGate",
|
||||
);
|
||||
|
||||
const items = rp.normalize(
|
||||
"obsigate",
|
||||
{ results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] },
|
||||
"https://og-dev.dracodev.net/",
|
||||
"idée",
|
||||
);
|
||||
ok(
|
||||
items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md",
|
||||
"lien profond ObsiGate",
|
||||
);
|
||||
ok(
|
||||
rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") ===
|
||||
"https://tiktak-dev.dracodev.net/search?q=q",
|
||||
"repli TikTak",
|
||||
);
|
||||
|
||||
console.log(`recherche.test.mjs : ${n} assertions OK`);
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests de la recherche fédérée ObsiGate (fusion #68).
|
||||
|
||||
Fonctions pures de backend/recherche_cors.py uniquement : aucune base,
|
||||
aucun serveur.
|
||||
"""
|
||||
|
||||
from backend.recherche_cors import (
|
||||
cors_headers_for,
|
||||
is_dev_host,
|
||||
is_prod_host,
|
||||
origin_of,
|
||||
preflight_headers,
|
||||
sister_origins,
|
||||
)
|
||||
|
||||
RESOLVED_DEV = {
|
||||
"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",
|
||||
"gitea": "https://git.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
def test_is_dev_host():
|
||||
assert is_dev_host("og-dev.dracodev.net") is True
|
||||
assert is_dev_host("og.dracodev.net") is False
|
||||
assert is_dev_host("x-dev.dracodev.net.evil") is False
|
||||
assert is_prod_host("og.dracodev.net") is True
|
||||
assert is_prod_host("og-dev.dracodev.net") is False
|
||||
|
||||
|
||||
def test_origin_of():
|
||||
assert origin_of("https://og-dev.dracodev.net") == "https://og-dev.dracodev.net"
|
||||
assert origin_of("https://og-dev.dracodev.net/chemin") == "https://og-dev.dracodev.net"
|
||||
assert origin_of("{{URL_OBSIGATE}}") is None
|
||||
|
||||
|
||||
def test_sister_origins_dev():
|
||||
assert sister_origins(RESOLVED_DEV) == {
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
def test_sister_origins_ajoute_env_de_la_requete():
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="og-dev.dracodev.net"
|
||||
)
|
||||
assert "https://og-dev.dracodev.net" not in sister_origins(
|
||||
{}, request_host="og.dracodev.net"
|
||||
)
|
||||
|
||||
|
||||
def test_cors_headers():
|
||||
headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net"
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
|
||||
assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None
|
||||
|
||||
|
||||
def test_preflight_headers():
|
||||
headers = preflight_headers("https://flowdeck-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
|
||||
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Validates:
|
||||
- Theme JSON structure validity
|
||||
- All 15 built-in themes have required keys and valid CSS colors
|
||||
- All 16 built-in themes have required keys and valid CSS colors
|
||||
- High-contrast and sepia mode generators produce valid CSS vars
|
||||
- Import/export round-trip
|
||||
- AVAILABLE_MODES includes all 4 modes
|
||||
@@ -80,9 +80,9 @@ class TestThemeStructure:
|
||||
assert '_genSepia' in self.content
|
||||
|
||||
def test_theme_count(self):
|
||||
"""Should have 15 built-in themes."""
|
||||
"""Should have 16 built-in themes."""
|
||||
names = _extract_theme_names(self.content)
|
||||
assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}"
|
||||
assert len(names) == 16, f"Expected 16 themes, found {len(names)}: {names}"
|
||||
|
||||
def test_required_export_functions(self):
|
||||
"""All required functions should be exported."""
|
||||
|
||||
Reference in New Issue
Block a user